PlatformInvoice 是 SaaS 平台应收聚合,不是法定电子发票,也不是法律服务账单。当前模型已经保存税前小计、税率、税额、含税总额与行项;它仍没有支付尝试、已付/退款余额、Credit Note、法定票号或总账分录。IssueInvoice 表示 Draft → Issued,并发布一条包含完整计价快照的集成事件。
1. 当前发票形状
| 字段 | 当前含义 |
|---|---|
InvoiceId / TenantId | 发票标识与可信租户范围 |
Period | 严格 yyyy-MM,参与幂等身份 |
Purpose | 服务端计费用途,最长 64 |
SubTotal | 行项税前小计之和,decimal(18,2) |
TaxRate | 套餐税率,decimal(5,4) |
TaxAmount | 各行税额汇总并保留两位 |
Amount | SubTotal + TaxAmount 的含税总额 |
LineItems | 描述、MeterCode、数量、单价、行小计、税率和行税额 |
Currency | 三个大写英文字母 |
Status | Draft、Issued、Paid、Overdue 或 Voided |
IdempotencyKey | Tenant+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 的处理顺序是:
- 严格验证 Period 为
yyyy-MM; - 读取当前租户订阅与其 Plan;
- 构造
tenant + period + monthly-platform-fee幂等键; - 查询到既有发票时直接返回;
- 否则由
UsageBillingCalculator按 PricingModel 生成行项和税额; - 创建 Draft,保存后返回完整
InvoiceSummary。
| PricingModel | 当前算法 |
|---|---|
| FlatRate | 一条 MonthlyPrice 固定费行 |
| Metered | 每条 RateCard 使用 max(0, TotalQuantity-IncludedQuantity) × OveragePrice |
| Hybrid | 固定费行与所有溢出行相加 |
另一个失败语义仍需修正:FindInvoiceByIdempotencyKeyAsync 的任何 Failure 都会进入“创建新发票”分支,不只明确 NotFound。存储不可用或超时应失败关闭,不能被当作不存在。
4. 发票状态机
同状态操作幂等成功;图外迁移返回 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.InvoiceIssuedIntegrationEventHandler 依赖 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 = AmountTaxRate = 0TaxAmount = 0LineItems = []因此生成/开具命令的即时响应含完整税与行项,稍后从列表读取同一发票却会丢失这些字段。目前又没有公开 Invoice detail endpoint。前端若展示税额或行项,不能把列表返回当作权威详情;GA 前应扩展标量投影或增加租户受限详情查询,并补双 ORM 合同。
7. 并发重放边界
SaveInvoiceAsync 同时检查数据库行和当前 UoW 的 pending-add。发现同 Tenant+Key 但不同 InvoiceId 时,它返回 Success 并忽略候选,却不把权威既有发票返回给调用方。调用方仍可能返回候选摘要,续费路径甚至可能用候选 InvoiceId 创建支付订单。
可靠的重复处理应:
- 只把明确 NotFound 当作创建条件;
- 捕获并规范化唯一冲突;
- 回读既有发票;
- 核对租户、账期、用途、金额、币种与行项摘要;
- 返回权威 InvoiceId 与状态;
- 记录重放、冲突和不一致指标。
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. 源码复核
# 计价、账期与发票不变量。rg -n "CalculateUsageChargesAsync|IsValidPeriod|GenerateDraft|LineItems|TaxAmount" \ src/Platform/PlatformBilling -g '*.cs'
# 幂等保存、列表投影与事件快照。rg -n "FindInvoiceByIdempotencyKeyAsync|SaveInvoiceAsync|ToInvoiceSummary|InvoiceIssuedIntegrationEvent" \ src/Platform/PlatformBilling -g '*.cs'