Billing (Invoices & Payments) — How it works
Overview
The billing engine issues invoices and records payments with relational financial totals (never JSONB-only), so a database or backup compromise or a JSONB parsing bug can never corrupt the money. It references patient/person/tenant/facility by id (logical, no cross-engine FK). Payment providers plug in behind a relational model; a provider failure is captured, not thrown.
Data model & ownership
Owned tables (migrations under core/src/main/resources/db/changelog/billing/):
| Table (migration) | Purpose |
|---|---|
invoice (001) | Header: relational status (`DRAFT |
invoice_line (001) | Line: line_total = quantity × unit_price − discount + tax; changing lines recomputes the invoice totals. |
payment_method (002) | Non-sensitive method config (type/provider/active), unique code per tenant. |
payment (002) | Relational status + amount, payment_reference unique per tenant, gateway snapshot in JSONB. No raw card secrets. |
charge_item (008) | The charge master (M13-007): what one billable thing costs at one facility, priced by orderable concept (exact) or order type (fallback), optionally anchored to a facility_service entry. billable=false = priced-but-free (covered). |
Key rules & invariants
-
Financial totals are relational and always match their lines — every line add/update/remove recomputes the line total (BigDecimal, scale 4, HALF_UP) and re-derives the invoice totals; lines are immutable once the invoice is
PAID/CANCELLED. -
Payments reconcile invoices — a
SUCCEEDEDpayment moves the invoice toPARTIALLY_PAID/PAID; a provider failure is stored as aFAILEDpayment with a non-sensitive gateway snapshot, not thrown. -
No raw card numbers or payment secrets are stored.
-
currency_codeis ISO-4217 uppercase Char(3) (TERM-008B). All seven billing tables (invoice,payment,payment_gateway_preference,insurance_claim,subscription,provider_payout,commission_transaction) store the same canonical form. Write boundaries (CurrencyCodes.normalisein mappers/services) fold case; DTOs accept[A-Za-z]{3}; migrationbilling/007uppercases existing rows and CHECK-constrains^[A-Z]{3}$(or null). Lowercase on the wire (usd) and uppercase (USD) therefore cannot diverge under aggregation. -
Charge capture is event-driven and idempotent (M13-007). When a clinical order completes,
ChargeCaptureListener(AFTER_COMMIT on the clinical engine'sOrderStatusChangedEvent, in aREQUIRES_NEWtransaction — a plain join would silently discard the writes) resolves the price from the charge master and adds a DRAFT invoice line to the patient's open draft invoice (found-or-created with a generatedCHG-number). The line records its provenance (source_type+source_reference), the listener refuses a second capture per order, and a partial unique index is the floor beneath that check. Unpriced completions and non-billable charges are skipped with visible log lines — "free" stays distinguishable from "forgotten". Billing never gates the order: a capture failure is logged, never propagated. Drafted invoices are reviewed, adjusted and issued like hand-written ones (FE-127). -
Pharmacy OTC / walk-in sales use the same invoice pipeline (
ChargeSourceType.SALE). Identified OTC callsPOST /api/v1/billing/invoices/ensure-draftand adds lines to that patient's draft. Anonymous walk-in creates a draft invoice with aSALE-number and nopatientId. ASALEline is allowed on a null-patient invoice;ORDER/ENCOUNTERlines are not. Pharmacy posts throughBillingCommandClient(it never importscore); a failed Feign call rolls the stock decrement back with the dispense transaction.
API
See the API Reference. Endpoint groups under /api/v1/billing: invoices (+ lines),
payments, payment methods, and the charge master (/charge-items, permission family
billing.charge.* — its own family because pricing the price list is finance configuration, not
cashiering).
Payer exchange (M12-008)
The provider side talks to an insurer through the PayerGateway SPI (exchange.spi.payer) —
transport-agnostic, adapters contributed by modules and deployments. Endpoints:
POST /api/v1/billing/eligibility-checks (the payer's coverage answer — UNKNOWN with a reason
when the payer cannot say, never a guess) and
POST /api/v1/billing/insurance-claims/{claimId}/submission (DRAFT → SUBMITTED with the payer's
payer_claim_reference; a payer-side refusal leaves the claim DRAFT and is returned, not thrown).
The payer's decision — synchronous or arriving later — always lands through M18-005's
POST /insurance-claims/{claimId}/decision; there is deliberately no second decision path.
Adapters shipped: simulated (default; deterministic, adjudicates synchronously in full, so
the whole flow runs without a payer contract — policy numbers ending -X are lapsed, blank is
UNKNOWN) and insurance-platform (the platform's own M18 payer: real eligibility, real intake
- auto-adjudication; pended claims answer later via the remittance post-back). A named adapter that is missing or unconfigured falls back to the simulator with a warn, and every exchange is stamped with the gateway that carried it.
Configuration & feature flags
| Property | Env var | Purpose |
|---|---|---|
billing.payer-gateway.provider | BILLING_PAYER_GATEWAY | Which PayerGateway adapter this deployment's exchanges go through (default simulated) |
Related features
demographic(bill a patient by id),tenant/facility(scope),notification(send receipts),pharmacy(OTCSALElines viaBillingCommandClient).