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)active → suspended → closed
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 chargedraft → posted → (optional reversed)
InvoiceSum(line_items) = subtotal; subtotal + tax = total; after issued, lines are immutable; void produces a reversing ledger entrydraft → issued → paid / partially_paid / voided
PaymentIdempotency-Key required; one allocation per line/invoice; payment_total = sum(allocations); reversing a payment requires refund not editpending → posted → (optional reversed)
RefundMust reference original payment; amount ≤ original amount − prior refunds; above-threshold requires approved_byrequested → pending_approval → approved → posted → (optional reversed) / rejected
AdjustmentMust reference account and reason code; contractual adjustments reference claim_id; corrections reference original ledger entrydraft → posted → (optional reversed)
StatementRunAsync job; statements are immutable artifacts once generated; re-running produces new run with new run_idqueued → running → completed / failed
PriceListEntries are effective-dated; no overlapping windows for (facility_id, code, currency)draft → published → retired
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