Billing Service — Domain Model
Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · FHIR-first
1. Aggregates
| Aggregate | Invariants | State machine |
|---|---|---|
| Account | One currency per account; balance = sum(ledger entries for account); only one active account per (patient_id, tenant_id, currency) | active → suspended → closed |
| Charge | Linked 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 charge | draft → posted → (optional reversed) |
| Invoice | Sum(line_items) = subtotal; subtotal + tax = total; after issued, lines are immutable; void produces a reversing ledger entry | draft → issued → paid / partially_paid / voided |
| Payment | Idempotency-Key required; one allocation per line/invoice; payment_total = sum(allocations); reversing a payment requires refund not edit | pending → posted → (optional reversed) |
| Refund | Must reference original payment; amount ≤ original amount − prior refunds; above-threshold requires approved_by | requested → pending_approval → approved → posted → (optional reversed) / rejected |
| Adjustment | Must reference account and reason code; contractual adjustments reference claim_id; corrections reference original ledger entry | draft → posted → (optional reversed) |
| StatementRun | Async job; statements are immutable artifacts once generated; re-running produces new run with new run_id | queued → running → completed / failed |
| PriceList | Entries 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 balance | terminal on insert |
2. Aggregate diagram
3. Entities
| Entity | Inside aggregate | Key fields |
|---|---|---|
InvoiceLineItem | Invoice | charge_id, code, units, unit_price, subtotal, tax, total |
PaymentAllocation | Payment | payment_id, invoice_id / line_id, amount |
TaxLine | Invoice | rate, jurisdiction, amount, rule_id |
StatementArtifact | StatementRun | run_id, statement_id, pdf_object_key, delivered_at? |
PriceEntry | PriceList | facility_id, code, currency, amount, effective_from, effective_to |
4. Value objects
| VO | Shape | Notes |
|---|---|---|
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, StatementRunId | Branded<string, ...> | Ulid with prefix (acc_, chr_, inv_, pay_, rfd_, adj_, srun_) |
IdempotencyKey | Branded<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)
| Invariant | Enforced by |
|---|---|
| An account balance equals the sum of its ledger entries | account_balances materialised view + application check on mutation |
| No invoice can be issued with zero charges | Invoice.issue() guard |
| No payment can exceed outstanding balance (unless overpayment explicit flag) | Account.applyPayment() guard |
| A refund cannot exceed its source payment | Refund.request() guard |
Once issued, invoice lines are immutable | DB trigger + use-case guard |
| Currency of payment must match account currency | Payment.post() guard |
| Adjustment reason must be configured in tenant reason-code list | Adjustment.post() guard |
6. Domain events
| Event | Emitted from | Aggregate root |
|---|---|---|
billing.charge.captured.v1 | Charge.post() | Charge |
billing.charge.reversed.v1 | Charge.reverse() | Charge |
billing.invoice.drafted.v1 | Invoice.create() | Invoice |
billing.invoice.issued.v1 | Invoice.issue() | Invoice |
billing.invoice.voided.v1 | Invoice.void() | Invoice |
billing.invoice.paid.v1 | Allocation brings balance to 0 | Invoice |
billing.payment.posted.v1 | Payment.post() | Payment |
billing.payment.reversed.v1 | Payment.reverse() | Payment |
billing.refund.requested.v1 | Refund.request() | Refund |
billing.refund.approved.v1 | Refund.approve() | Refund |
billing.refund.issued.v1 | Refund.post() | Refund |
billing.refund.rejected.v1 | Refund.reject() | Refund |
billing.adjustment.applied.v1 | Adjustment.post() | Adjustment |
billing.statement.generated.v1 | StatementRun.complete() | StatementRun |
billing.statement.delivered.v1 | Delivery adapter ack | StatementRun |
billing.account.suspended.v1 | Account.suspend() | Account |
7. Ubiquitous language
| Term | Meaning |
|---|---|
| Charge | A billable item for services rendered; creates a ChargeItem FHIR resource and a ledger entry |
| Account | The patient's AR record with balance and ledger. One per patient+currency+tenant |
| Ledger entry | Append-only financial posting (charge, payment, refund, adjustment) |
| Invoice | A bundled statement of charges the patient is expected to pay at a moment in time |
| Statement | A rendered artifact (PDF/HTML) summarising account activity for a period, delivered to the patient |
| Adjustment | A non-cash modification to balance (write-off, contractual discount, courtesy, correction) |
| Aging bucket | Buckets 0-30, 31-60, 61-90, 91-120, 121+ days since invoice issue |
| Remittance | Payer's advice of payment with allocations; ingested from claims-service |
| Contractual adjustment | Difference between billed and contracted amount with payer; auto-posted from remittance |
| Write-off | Administrative zeroing of outstanding balance (bad debt, courtesy) |
| Copay | Patient-responsibility portion known at service time |
| Coinsurance | Patient-responsibility percentage after payer adjudication |
| Deductible | Patient-responsibility amount before payer participation starts |
| Idempotency-Key | Client-supplied unique key for safe retries of financial mutations |
| Licensing gate | Runtime check that the tenant has the billing module enabled |
8. State machines
Charge
Invoice
Refund
9. Domain errors
| Error | HTTP | Meaning |
|---|---|---|
MoneyCurrencyMismatch | 400 | Payment currency ≠ account currency |
LedgerImmutable | 409 | Attempt to edit a posted ledger entry |
InvoiceAlreadyIssued | 409 | Cannot modify lines after issue |
InvoiceNotFound | 404 | Missing invoice |
AccountNotFound | 404 | Missing account |
RefundExceedsPayment | 400 | Refund amount > original payment − prior refunds |
RefundRequiresApproval | 403 | Above threshold without billing:refund:approve |
IdempotencyConflict | 409 | Same key, different payload |
CrossTenantReference | 403 | Reference across tenants |
PriceNotFound | 404 | No active price for (facility, code, service_date, currency) |
ModuleNotActive | 403 | Tenant unlicensed for billing |
ChargeCodeUnknown | 400 | Code not valid in terminology |
TaxRuleMissing | 500 | Tax rule missing for facility + effective date |