Skip to content
bitzorcas
中EN

Guide

Billing 账单、状态机与幂等

解释 PlatformInvoice 的含税行项、按套餐模型计价、SHA-256 幂等、状态转换、列表投影、事件与当前财务边界。

Last updated

PlatformInvoice 是 SaaS 平台应收聚合,不是法定电子发票,也不是法律服务账单。当前模型已经保存税前小计、税率、税额、含税总额与行项;它仍没有支付尝试、已付/退款余额、Credit Note、法定票号或总账分录。IssueInvoice 表示 Draft → Issued,并发布一条包含完整计价快照的集成事件。

1. 当前发票形状

字段当前含义
InvoiceId / TenantId发票标识与可信租户范围
Period严格 yyyy-MM,参与幂等身份
Purpose服务端计费用途,最长 64
SubTotal行项税前小计之和,decimal(18,2)
TaxRate套餐税率,decimal(5,4)
TaxAmount各行税额汇总并保留两位
AmountSubTotal + TaxAmount 的含税总额
LineItems描述、MeterCode、数量、单价、行小计、税率和行税额
Currency三个大写英文字母
StatusDraft、Issued、Paid、Overdue 或 Voided
IdempotencyKeyTenant+Period+Purpose 的 SHA-256 摘要

含税入口要求至少一条行项,LineTotal 和 TaxAmount 最多两位,UnitPrice 最多四位,Quantity 最多六位。行小计汇总与传入 SubTotal 允许 ±0.01 的容差;税额由行项汇总,不再信任单独传入的总税额。

生成一张含税草稿
// 先显式构造每个金额组成部分,再交给聚合校验。
var line = new InvoiceLineItem(
Description: "月度固定费用",
MeterCode: string.Empty,
Quantity: 1m,
UnitPrice: 199m,
LineTotal: 199m,
TaxRate: 0.06m,
TaxAmount: 11.94m);
// GenerateDraft 校验账期并重新计算最终金额。
var draft = PlatformInvoice.GenerateDraft(
tenantId: "tenant-100",
period: "2026-08",
purpose: PlatformBillingConstants.MonthlyInvoicePurpose,
subTotal: 199m,
taxRate: 0.06m,
currency: "CNY",
lineItems: [line],
now: clock.UtcNow);
// Amount 由聚合计算为 210.94,不接受客户端直接指定。
return draft;

旧的不含税 GenerateDraft(amount, currency, now) 仍保留用于兼容续费等调用方。它写入 SubTotal=Amount、TaxRate=0、TaxAmount=0、空行项;新月账单走含税行项入口。

2. 幂等键协议

InvoiceIdempotency.BuildKey 对每个分量先做长度前缀,再计算 SHA-256:

canonical = {tenantLength}:{tenant}|{periodLength}:{period}|{purposeLength}:{purpose}
key = invoice:{lowercase SHA-256 hex}

长度前缀避免分隔符歧义。数据库再用 (TenantId, IdempotencyKey) 唯一索引兜底。应用键定义“哪一次业务动作可以重放”,唯一索引负责并发竞争,两者职责不同。

重试时复用稳定账期与用途
var key = InvoiceIdempotency.BuildKey(
currentUser.User.TenantId,
"2026-08",
PlatformBillingConstants.MonthlyInvoicePurpose);
// 日志只保留短后缀,避免把完整业务身份当作普通诊断字段扩散。
logger.LogInformation("Invoice key suffix {Suffix}", key[^8..]);

3. 月账单生成算法

POST /api/platform-billing/invoices/monthly 的处理顺序是:

  1. 严格验证 Period 为 yyyy-MM;
  2. 读取当前租户订阅与其 Plan;
  3. 构造 tenant + period + monthly-platform-fee 幂等键;
  4. 查询到既有发票时直接返回;
  5. 否则由 UsageBillingCalculator 按 PricingModel 生成行项和税额;
  6. 创建 Draft,保存后返回完整 InvoiceSummary。
UsageBillingCalculatorRepositoryGenerateMonthlyInvoiceCallerUsageBillingCalculatorRepositoryGenerateMonthlyInvoiceCalleralt[existing][lookup failure]yyyy-MMsubscription + Planinvoice by idempotency keyauthoritative summarytenant + Planall tenant usage metersline items + tax totalssave Draftnew summary
PricingModel当前算法
FlatRate一条 MonthlyPrice 固定费行
Metered每条 RateCard 使用 max(0, TotalQuantity-IncludedQuantity) × OveragePrice
Hybrid固定费行与所有溢出行相加

另一个失败语义仍需修正:FindInvoiceByIdempotencyKeyAsync 的任何 Failure 都会进入“创建新发票”分支,不只明确 NotFound。存储不可用或超时应失败关闭,不能被当作不存在。

4. 发票状态机

GenerateDraftIssueVoidverified payment callbackMarkOverdueVoidverified payment callbackVoid

Draft

Issued

Voided

Paid

Overdue

同状态操作幂等成功;图外迁移返回 PlatformBilling.Invoice.TransitionInvalid。Paid 与 Voided 是终态。公开 API 有 Issue 与 Void,没有 MarkOverdue 或直接 MarkPaid 管理端点;Paid 由支付回调推进。

Adjust(delta) 只允许 Draft,且当 SubTotal 或 TaxAmount 非零时拒绝。兼容入口会把非零 amount 同时写入 SubTotal,因此普通非零发票不能直接调整;当前也没有公开 Adjust 命令。需要调价时应重建行项或建立 CreditNote,而不是绕过总额一致性。

5. 开具与事件快照

IssueInvoiceCommandHandler 按当前 TenantId 加载发票,执行 Issue、保存,再发布 InvoiceIssuedIntegrationEvent。事件包含:

  • InvoiceId、TenantId、Period、Purpose;
  • Amount、SubTotal、TaxRate、TaxAmount、Currency;
  • IdempotencyKey、完整 LineItems、IssuedAt。

稳定事件名由 owner-local 目录登记:

BitzOrcas.Platform.PlatformBilling.Contracts.PlatformBilling.InvoiceIssuedIntegrationEvent

Handler 依赖 generated endpoint 的事务管线和生产 IIntegrationEventPublisher/CAP Outbox。直接 new Handler 的单元测试不能证明数据库与 Outbox 原子提交;应在生产组合下做提交、发布失败和回滚注入。

6. 列表读模型会丢失计价细节

GET /api/platform-billing/invoices 已迁到 Query Shape,支持 PageIndex、PageSize、关键词和 Status,固定按 CreateTime、InvoiceId 倒序。租户条件与软删除基线在数据库下推,这比旧的全量内存排序可靠。

但当前标量投影只读取 Amount、Currency、Status 等摘要字段。ToInvoiceSummary 会重建:

SubTotal = Amount
TaxRate = 0
TaxAmount = 0
LineItems = []

因此生成/开具命令的即时响应含完整税与行项,稍后从列表读取同一发票却会丢失这些字段。目前又没有公开 Invoice detail endpoint。前端若展示税额或行项,不能把列表返回当作权威详情;GA 前应扩展标量投影或增加租户受限详情查询,并补双 ORM 合同。

7. 并发重放边界

SaveInvoiceAsync 同时检查数据库行和当前 UoW 的 pending-add。发现同 Tenant+Key 但不同 InvoiceId 时,它返回 Success 并忽略候选,却不把权威既有发票返回给调用方。调用方仍可能返回候选摘要,续费路径甚至可能用候选 InvoiceId 创建支付订单。

可靠的重复处理应:

  1. 只把明确 NotFound 当作创建条件;
  2. 捕获并规范化唯一冲突;
  3. 回读既有发票;
  4. 核对租户、账期、用途、金额、币种与行项摘要;
  5. 返回权威 InvoiceId 与状态;
  6. 记录重放、冲突和不一致指标。

8. 作废、退款与法定票据

Void 只把 Draft/Issued/Overdue 改成 Voided。它不生成负数发票、退款、Credit Note、总账分录或原单关联,Paid 也不能 Void。

商业结算还需要独立表达:

  • PaymentAttempt/Transaction 和 Provider trade id;
  • Paid、Refunded、Balance 与部分退款;
  • CreditNote、Chargeback、审批和原因;
  • 法定票号、电子发票文件和税务状态;
  • 关账水位、迟到用量、冲销与重开;
  • 不可变审计、导出和保留策略。

不要继续用可空字段把 PlatformInvoice 扩成总账。应收、支付尝试、法定票据和会计凭证属于不同边界。

9. 测试与发布门禁

  • FlatRate/Metered/Hybrid 的行项、税额、舍入、溢出和空用量;
  • Period 合法性、用量闭开窗口、迟到事实和跨月不重复;
  • NotFound 与 Store Failure 分离;
  • 并发生成、唯一冲突回读与 pending-add 等价;
  • 列表投影保留税与行项,或详情 API 返回权威快照;
  • Issue 保存+Outbox 的原子提交与回滚;
  • 支付回调只允许 Issued/Overdue → Paid;
  • 跨租户查询、Issue/Void 攻击与权限;
  • InvoiceIssuedIntegrationEvent 的版本与 byte contract。

10. 源码复核

Terminal window
# 计价、账期与发票不变量。
rg -n "CalculateUsageChargesAsync|IsValidPeriod|GenerateDraft|LineItems|TaxAmount" \
src/Platform/PlatformBilling -g '*.cs'
# 幂等保存、列表投影与事件快照。
rg -n "FindInvoiceByIdempotencyKeyAsync|SaveInvoiceAsync|ToInvoiceSummary|InvoiceIssuedIntegrationEvent" \
src/Platform/PlatformBilling -g '*.cs'

返回 Billing 总览 · 权益、配额与用量 · 支付与回调

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%