Skip to main content

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 SUCCEEDED payment moves the invoice to PARTIALLY_PAID/PAID; a provider failure is stored as a FAILED payment with a non-sensitive gateway snapshot, not thrown.

  • No raw card numbers or payment secrets are stored.

  • currency_code is 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.normalise in mappers/services) fold case; DTOs accept [A-Za-z]{3}; migration billing/007 uppercases 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's OrderStatusChangedEvent, in a REQUIRES_NEW transaction — 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 generated CHG- 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 calls POST /api/v1/billing/invoices/ensure-draft and adds lines to that patient's draft. Anonymous walk-in creates a draft invoice with a SALE- number and no patientId. A SALE line is allowed on a null-patient invoice; ORDER / ENCOUNTER lines are not. Pharmacy posts through BillingCommandClient (it never imports core); 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

PropertyEnv varPurpose
billing.payer-gateway.providerBILLING_PAYER_GATEWAYWhich PayerGateway adapter this deployment's exchanges go through (default simulated)
  • demographic (bill a patient by id), tenant/facility (scope), notification (send receipts), pharmacy (OTC SALE lines via BillingCommandClient).