PlatformInvoice is a SaaS receivable aggregate, not a statutory e-invoice or legal-service bill. It now stores pretax subtotal, rate, tax, tax-inclusive total, and line items. It still has no payment attempts, paid/refunded balance, credit note, statutory number, or ledger entry. IssueInvoice means Draft → Issued and publishes the complete rating snapshot.
1. Current invoice shape
| Field | Current meaning |
|---|---|
InvoiceId / TenantId | invoice identity and trusted tenant scope |
Period | strict yyyy-MM, part of idempotency identity |
Purpose | server-owned billing purpose, length 64 |
SubTotal | sum of pretax line totals, decimal(18,2) |
TaxRate | plan tax rate, decimal(5,4) |
TaxAmount | rounded sum of line taxes |
Amount | tax-inclusive SubTotal + TaxAmount |
LineItems | description, MeterCode, quantity, unit price, line total, tax rate, tax |
Currency | three uppercase ASCII letters |
Status | Draft, Issued, Paid, Overdue, or Voided |
IdempotencyKey | SHA-256 of Tenant+Period+Purpose |
The taxed factory requires at least one line. LineTotal and TaxAmount allow two decimals, UnitPrice four, and Quantity six. The supplied SubTotal may differ from the line sum by at most ±0.01; tax is derived from line taxes rather than a separately trusted total.
// Build every monetary component explicitly before aggregate validation.var line = new InvoiceLineItem( Description: "Monthly fixed fee", MeterCode: string.Empty, Quantity: 1m, UnitPrice: 199m, LineTotal: 199m, TaxRate: 0.06m, TaxAmount: 11.94m);
// GenerateDraft validates the period and recomputes the final amount.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);
// The aggregate derives Amount as 210.94; callers do not submit it.return draft;The legacy untaxed GenerateDraft(amount, currency, now) remains for renewal compatibility. It stores SubTotal=Amount, zero tax, and no lines. New monthly generation uses the taxed line-item overload.
2. Idempotency protocol
InvoiceIdempotency.BuildKey length-prefixes every component before hashing:
canonical = {tenantLength}:{tenant}|{periodLength}:{period}|{purposeLength}:{purpose}key = invoice:{lowercase SHA-256 hex}Length prefixes eliminate separator ambiguity. The database unique index on (TenantId, IdempotencyKey) is the concurrency backstop. The application key defines replay identity; the index arbitrates races.
var key = InvoiceIdempotency.BuildKey( currentUser.User.TenantId, "2026-08", PlatformBillingConstants.MonthlyInvoicePurpose);
// A short suffix is enough for ordinary diagnostics.logger.LogInformation("Invoice key suffix {Suffix}", key[^8..]);3. Monthly generation algorithm
POST /api/platform-billing/invoices/monthly:
- strictly validates
yyyy-MM; - loads the current subscription and Plan;
- builds
tenant + period + monthly-platform-fee; - returns an existing invoice when found;
- otherwise asks
UsageBillingCalculatorfor lines and tax under the PricingModel; - saves a Draft and returns the full
InvoiceSummary.
| PricingModel | Current algorithm |
|---|---|
| FlatRate | one MonthlyPrice line |
| Metered | each RateCard uses max(0, TotalQuantity-IncludedQuantity) × OveragePrice |
| Hybrid | fixed-fee line plus all overage lines |
One failure rule also remains unsafe: any FindInvoiceByIdempotencyKeyAsync Failure enters the creation branch, not just explicit NotFound. Store outage or timeout must fail closed.
4. State machine
Repeating the current state succeeds. Other edges return PlatformBilling.Invoice.TransitionInvalid. Paid and Voided are terminal. Public API exposes Issue and Void, not MarkOverdue or an administrative MarkPaid; payment callback owns Paid.
Adjust(delta) requires Draft and rejects any nonzero SubTotal or TaxAmount. The compatibility factory sets SubTotal to a nonzero amount, so an ordinary nonzero invoice is not directly adjustable. There is no public Adjust command. Rebuild lines or model a CreditNote instead of bypassing total consistency.
5. Issuance and event snapshot
IssueInvoiceCommandHandler loads by current TenantId, applies Issue, saves, and publishes InvoiceIssuedIntegrationEvent with:
- InvoiceId, TenantId, Period, Purpose;
- Amount, SubTotal, TaxRate, TaxAmount, Currency;
- IdempotencyKey, complete LineItems, and IssuedAt.
The owner-local catalog exposes this stable event name:
BitzOrcas.Platform.PlatformBilling.Contracts.PlatformBilling.InvoiceIssuedIntegrationEventThe handler relies on the generated transaction pipeline and production IIntegrationEventPublisher/CAP Outbox. A directly constructed handler test does not prove database/outbox atomicity; production composition needs commit, publish-failure, and rollback injection.
6. The invoice list loses rating detail
GET /api/platform-billing/invoices now uses Query Shape with PageIndex, PageSize, keyword, and Status, fixed by CreateTime then InvoiceId descending. Tenant and soft-delete baselines are pushed to the database.
The scalar projection nevertheless selects only Amount, Currency, Status, and basic identity. ToInvoiceSummary reconstructs:
SubTotal = AmountTaxRate = 0TaxAmount = 0LineItems = []Creation/issuance responses therefore contain full tax and lines, while a later list read of the same invoice loses them. There is no public invoice-detail endpoint. A UI must not treat the list as authoritative detail; release should extend the projection or add a tenant-scoped detail query and dual-ORM contract.
7. Concurrent replay boundary
SaveInvoiceAsync checks persisted rows and pending adds. For the same tenant/key with another InvoiceId, it returns Success and ignores the candidate without returning the authoritative invoice. The caller may still return the candidate summary; renewal may even create a payment order with its unpersisted InvoiceId.
Reliable replay should:
- create only after explicit NotFound;
- normalize unique conflicts;
- reload the existing invoice;
- compare tenant, period, purpose, amount, currency, and line digest;
- return authoritative InvoiceId and status;
- measure replay, conflict, and mismatch.
8. Void, refund, and statutory documents
Void only moves Draft/Issued/Overdue to Voided. It creates no negative invoice, refund, credit note, ledger entry, or reversal relation; Paid cannot be voided.
Commercial settlement still needs separate representations for payment attempts and provider trade IDs, paid/refunded/balance, credit notes and chargebacks, statutory numbers and tax documents, period-closing watermarks and late facts, and immutable audited export/retention.
Do not turn PlatformInvoice into a general ledger through nullable fields. Receivable, payment attempt, statutory document, and accounting entry are different boundaries.
9. Test and release gates
- FlatRate/Metered/Hybrid lines, tax, rounding, overflow, and empty usage;
- valid periods, half-open usage windows, late facts, and no cross-month repeat;
- explicit NotFound versus store failure;
- concurrent creation, authoritative reload, and pending-add parity;
- list detail preservation or a dedicated authoritative detail API;
- Issue save+Outbox atomic commit and rollback;
- callback-only Issued/Overdue → Paid;
- cross-tenant list/Issue/Void attacks and permissions;
- versioned
InvoiceIssuedIntegrationEventbyte contract.
10. Source review
# Rating, period, and invoice invariants.rg -n "CalculateUsageChargesAsync|IsValidPeriod|GenerateDraft|LineItems|TaxAmount" \ src/Platform/PlatformBilling -g '*.cs'
# Idempotent persistence, list projection, and event snapshot.rg -n "FindInvoiceByIdempotencyKeyAsync|SaveInvoiceAsync|ToInvoiceSummary|InvoiceIssuedIntegrationEvent" \ src/Platform/PlatformBilling -g '*.cs'Back to Billing · Entitlements and usage · Payments and callbacks