Skip to content
bitzorcas
中EN

Guide

Billing Invoices, State, and Idempotency

Explains taxed invoice lines, plan-model rating, SHA-256 idempotency, transitions, list projection, events, and current financial boundaries.

Last updated

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

FieldCurrent meaning
InvoiceId / TenantIdinvoice identity and trusted tenant scope
Periodstrict yyyy-MM, part of idempotency identity
Purposeserver-owned billing purpose, length 64
SubTotalsum of pretax line totals, decimal(18,2)
TaxRateplan tax rate, decimal(5,4)
TaxAmountrounded sum of line taxes
Amounttax-inclusive SubTotal + TaxAmount
LineItemsdescription, MeterCode, quantity, unit price, line total, tax rate, tax
Currencythree uppercase ASCII letters
StatusDraft, Issued, Paid, Overdue, or Voided
IdempotencyKeySHA-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.

Generate a taxed draft
// 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.

Reuse stable period and purpose on retry
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:

  1. strictly validates yyyy-MM;
  2. loads the current subscription and Plan;
  3. builds tenant + period + monthly-platform-fee;
  4. returns an existing invoice when found;
  5. otherwise asks UsageBillingCalculator for lines and tax under the PricingModel;
  6. saves a Draft and returns the full InvoiceSummary.
UsageBillingCalculatorRepositoryGenerateMonthlyInvoiceCallerUsageBillingCalculatorRepositoryGenerateMonthlyInvoiceCalleralt[existing][lookup failure]yyyy-MMsubscription + Planinvoice by idempotency keyauthoritative summarytenant + Planall tenant usage meterslines + tax totalssave Draftnew summary
PricingModelCurrent algorithm
FlatRateone MonthlyPrice line
Meteredeach RateCard uses max(0, TotalQuantity-IncludedQuantity) × OveragePrice
Hybridfixed-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

GenerateDraftIssueVoidverified payment callbackMarkOverdueVoidverified payment callbackVoid

Draft

Issued

Voided

Paid

Overdue

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.InvoiceIssuedIntegrationEvent

The 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 = Amount
TaxRate = 0
TaxAmount = 0
LineItems = []

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:

  1. create only after explicit NotFound;
  2. normalize unique conflicts;
  3. reload the existing invoice;
  4. compare tenant, period, purpose, amount, currency, and line digest;
  5. return authoritative InvoiceId and status;
  6. 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 InvoiceIssuedIntegrationEvent byte contract.

10. Source review

Terminal window
# 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

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%