Skip to main content

Billing Service — Domain Model

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · FHIR-first

1. Aggregates

AggregateInvariantsState machine
AccountOne currency per account; balance = sum(ledger entries for account); only one active account per (patient_id, tenant_id, currency)activesuspendedclosed
ChargeLinked to patient_id + encounter_id (or order_id, appointment_id, immunization_id); must resolve a price; once posted it is immutable and can only be reversed via a new reversing chargedraftposted → (optional reversed)
InvoiceSum(line_items) = subtotal; subtotal + tax = total; after issued, lines are immutable; void produces a reversing ledger entrydraftissuedpaid / partially_paid / voided
PaymentIdempotency-Key required; one allocation per line/invoice; payment_total = sum(allocations); reversing a payment requires refund not editpendingposted → (optional reversed)
RefundMust reference original payment; amount ≤ original amount − prior refunds; above-threshold requires approved_byrequestedpending_approvalapprovedposted → (optional reversed) / rejected
AdjustmentMust reference account and reason code; contractual adjustments reference claim_id; corrections reference original ledger entrydraftposted → (optional reversed)
StatementRunAsync job; statements are immutable artifacts once generated; re-running produces new run with new run_idqueuedrunningcompleted / failed
PriceListEntries are effective-dated; no overlapping windows for (facility_id, code, currency)draftpublishedretired
LedgerEntry (inside Account)Append-only; once persisted, never mutated; reversals are new rows; sum of entries = account balanceterminal on insert

2. Aggregate diagram

3. Entities

EntityInside aggregateKey fields
InvoiceLineItemInvoicecharge_id, code, units, unit_price, subtotal, tax, total
PaymentAllocationPaymentpayment_id, invoice_id / line_id, amount
TaxLineInvoicerate, jurisdiction, amount, rule_id
StatementArtifactStatementRunrun_id, statement_id, pdf_object_key, delivered_at?
PriceEntryPriceListfacility_id, code, currency, amount, effective_from, effective_to

4. Value objects

VOShapeNotes
Money{ currency: ISO-4217, minor_units: bigint }Bank-style. Frozen in F-BILL-01
ChargeCode{ system: "CPT"|"HCPCS"|"ICHI"|"local", code, display? }Validated against terminology-service
Modifier{ system, code, display? }Up to 4 per charge
AccountId, ChargeId, InvoiceId, PaymentId, RefundId, AdjustmentId, StatementRunIdBranded<string, ...>Ulid with prefix (acc_, chr_, inv_, pay_, rfd_, adj_, srun_)
IdempotencyKeyBranded<string, 'IdempotencyKey'>SHA-256 hash stored
Aging{ "0-30": Money, "31-60": Money, "61-90": Money, "91-120": Money, "121+": Money }Computed at query time or materialised

5. Invariants (cross-aggregate)

InvariantEnforced by
An account balance equals the sum of its ledger entriesaccount_balances materialised view + application check on mutation
No invoice can be issued with zero chargesInvoice.issue() guard
No payment can exceed outstanding balance (unless overpayment explicit flag)Account.applyPayment() guard
A refund cannot exceed its source paymentRefund.request() guard
Once issued, invoice lines are immutableDB trigger + use-case guard
Currency of payment must match account currencyPayment.post() guard
Adjustment reason must be configured in tenant reason-code listAdjustment.post() guard

6. Domain events

EventEmitted fromAggregate root
billing.charge.captured.v1Charge.post()Charge
billing.charge.reversed.v1Charge.reverse()Charge
billing.invoice.drafted.v1Invoice.create()Invoice
billing.invoice.issued.v1Invoice.issue()Invoice
billing.invoice.voided.v1Invoice.void()Invoice
billing.invoice.paid.v1Allocation brings balance to 0Invoice
billing.payment.posted.v1Payment.post()Payment
billing.payment.reversed.v1Payment.reverse()Payment
billing.refund.requested.v1Refund.request()Refund
billing.refund.approved.v1Refund.approve()Refund
billing.refund.issued.v1Refund.post()Refund
billing.refund.rejected.v1Refund.reject()Refund
billing.adjustment.applied.v1Adjustment.post()Adjustment
billing.statement.generated.v1StatementRun.complete()StatementRun
billing.statement.delivered.v1Delivery adapter ackStatementRun
billing.account.suspended.v1Account.suspend()Account

7. Ubiquitous language

TermMeaning
ChargeA billable item for services rendered; creates a ChargeItem FHIR resource and a ledger entry
AccountThe patient's AR record with balance and ledger. One per patient+currency+tenant
Ledger entryAppend-only financial posting (charge, payment, refund, adjustment)
InvoiceA bundled statement of charges the patient is expected to pay at a moment in time
StatementA rendered artifact (PDF/HTML) summarising account activity for a period, delivered to the patient
AdjustmentA non-cash modification to balance (write-off, contractual discount, courtesy, correction)
Aging bucketBuckets 0-30, 31-60, 61-90, 91-120, 121+ days since invoice issue
RemittancePayer's advice of payment with allocations; ingested from claims-service
Contractual adjustmentDifference between billed and contracted amount with payer; auto-posted from remittance
Write-offAdministrative zeroing of outstanding balance (bad debt, courtesy)
CopayPatient-responsibility portion known at service time
CoinsurancePatient-responsibility percentage after payer adjudication
DeductiblePatient-responsibility amount before payer participation starts
Idempotency-KeyClient-supplied unique key for safe retries of financial mutations
Licensing gateRuntime check that the tenant has the billing module enabled

8. State machines

Charge

Invoice

Refund

9. Domain errors

ErrorHTTPMeaning
MoneyCurrencyMismatch400Payment currency ≠ account currency
LedgerImmutable409Attempt to edit a posted ledger entry
InvoiceAlreadyIssued409Cannot modify lines after issue
InvoiceNotFound404Missing invoice
AccountNotFound404Missing account
RefundExceedsPayment400Refund amount > original payment − prior refunds
RefundRequiresApproval403Above threshold without billing:refund:approve
IdempotencyConflict409Same key, different payload
CrossTenantReference403Reference across tenants
PriceNotFound404No active price for (facility, code, service_date, currency)
ModuleNotActive403Tenant unlicensed for billing
ChargeCodeUnknown400Code not valid in terminology
TaxRuleMissing500Tax rule missing for facility + effective date