Billing
Owns premium and claim charges, invoices, payments, adjustments, and the double-entry ledger: the financial settlement layer between the plan and its members.
The Billing service tracks all money movement on the platform. Charges are the atomic billable events (premium, tax, fee, claim reimbursement). Invoices group charges into payable units. Payments settle invoices, adjustments handle off-cycle credits and debits, and payments and adjustments post balancing entries to the ledger. Charge debits are not yet posted (the CHARGE entry type is defined but unwritten), so the ledger currently records payments and adjustments only, not a complete double-entry view.
Billing generates charges and draft invoices reactively, from enrollment and claims Kafka events, not on a recurring billing-cycle cron. A transactional outbox publishes billing's own events so they reach Kafka even if the broker is briefly unavailable.
Field reference: full columns, types, and nullability live in the catalog: glossary terms
Charge,Invoice,InvoiceLineItem,Payment,Adjustment,LedgerEntry,InstallmentSchedule,AutopayPreference. The Billing & Payments domain page is the narrative on the ledger model. This page is the service contract.
Responsibilities
- Generate premium charges and a draft invoice on enrollment lifecycle events (policy issued, renewed, reinstated, endorsed)
- Generate claim-reimbursement charges when a claim is approved
- Finalise invoices, record payments, and mark invoices PAID once settled
- Manage adjustments (credit/debit, apply/reverse)
- Maintain a double-entry ledger of all financial movements
- Mark overdue invoices delinquent and lapse the associated policy
- Serve Explanation of Benefits (EOB) summaries computed from claim charges
- Publish billing events; expose an internal invoice lookup for the Claims service
Database
Schema: billing
| Table | Purpose |
|---|---|
charges | Individual charges (PREMIUM / TAX / FEE / CLAIM) per coverage period |
invoices | Grouped invoices; lifecycle DRAFT → FINALISED → PAID (or VOID), plus DELINQUENT / LAPSED |
invoice_line_items | Line items, each referencing one charge with its amount copied across |
payments | Payment transactions; settle exactly one invoice |
adjustments | Manual and automated credits/debits |
ledger_entries | Double-entry ledger of all financial movements |
installment_schedules | Split a total into N invoices (table exists; service not wired, see caveats) |
autopay_preferences | Per-policy autopay flag and method (table exists; no API, see caveats) |
outbox | Transactional outbox for reliable Kafka publishing |
projection_checkpoints | Consumer offset tracking for the enrollment + claims projections |
Statuses, entry_type, direction, method, and frequency are free TEXT with no DB CHECK; the application enforces the conventions. Full column detail is in the catalog.
API Routes
All JWT routes are validated against Keycloak JWKS and tenancy-scoped (see Tenancy). The /{resource}/list variants are aliases of the documented list endpoints, used by web-admin through APISIX.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /charges | JWT | List charges (filterable) |
GET | /charges/{locator} | JWT | Get charge by locator |
PATCH | /charges/{locator}/void | JWT | Void a charge |
GET | /invoices | JWT | List invoices (filterable) |
GET | /invoices/{locator} | JWT | Get invoice by locator |
PATCH | /invoices/{locator}/finalise | JWT | Finalise a DRAFT invoice (sets total, due date) |
PATCH | /invoices/{locator}/void | JWT | Void an invoice |
POST | /invoices/{locator}/number/generate | JWT | Generate an invoice display number |
POST | /payments | JWT | Record a payment against a FINALISED invoice |
GET | /payments | JWT | List payments (filterable) |
GET | /payments/{locator} | JWT | Get payment by locator |
PATCH | /payments/{locator}/void | JWT | Void a SETTLED payment |
POST | /adjustments | JWT | Create a billing adjustment |
GET | /adjustments | JWT | List adjustments |
GET | /adjustments/{locator} | JWT | Get adjustment by locator |
PATCH | /adjustments/{locator}/apply | JWT | Apply a PENDING adjustment |
PATCH | /adjustments/{locator}/reverse | JWT | Reverse an APPLIED adjustment |
GET | /ledger | JWT | List ledger entries |
GET | /ledger/{locator} | JWT | Get a ledger entry |
GET | /eob | JWT | List EOB summaries (grouped by claim) |
GET | /eob/{claimLocator} | JWT | Get EOB for a claim |
GET | /payment-method | JWT | Saved card on file: {onFile, brand?, last4?, expMonth?, expYear?}; no card is 200 {onFile: false} |
POST | /onboarding/payment-intents | None (pre-account funnel) | Create a Stripe PaymentIntent for the quote funnel (amountPence = pricePerMember x headcount, card saved for off-session reuse) |
POST | /stripe/webhook | Stripe-Signature | Stripe webhook receiver (see below) |
GET | /internal/invoices/{locator} | Internal | Fetch invoice for Claims (no JWT; 404-blocked at the APISIX edge) |
Tenancy
Two caller kinds: a tenant carries the org_locator JWT claim (the employer party locator) and is confined to its own objects; a privileged caller has an admin realm role (admin, mcp:operator; override via BILLING_ADMIN_ROLES) and no org_locator. A token with neither gets 403. Invoices and the payment method are matched on org_locator; charges, payments, adjustments, ledger and installments are matched through the org's deterministic billing account id. A foreign invoice returns 404, identical to one that does not exist, so sequential locators are not an existence oracle. List endpoints force the caller's claim over any query parameter. /internal/*, /onboarding/payment-intents and /stripe/webhook are deliberately outside tenancy (no user JWT there).
Stripe payments
POST /stripe/webhook verifies the Stripe-Signature header over the raw body; a missing STRIPE_WEBHOOK_SECRET fails closed with 503. Only payment_intent.succeeded is acted on (everything else is acked). Settlement is idempotent on payments.stripe_payment_intent_id (in-code check plus a partial unique index; a redelivery is a 200 no-op) and writes, in one transaction: a PAID premium charge, an invoice that goes straight to PAID (finalised and paid in the same step, skipping the DRAFT/finalise path), a line item, a SETTLED card payment, a PAYMENT/CREDIT ledger entry, and an invoice.paid outbox event. The saved card (brand/last4/expiry) is copied onto the employer's billing account after the transaction, best-effort, which is what GET /payment-method serves. Policy issuance is deliberately not done here; the onboarding composer in group-scheme owns it.
POST /installments and PATCH /installments/{locator}/cancel are live: main.go constructs InstallmentService (with the outbox wired via WithOutbox) and injects it as InstallmentSvc, so the routes register and schedule create/cancel emit installment.created / installment.cancelled transactionally. Autopay has a table (migration 0014) but still no handler, job, or collection logic.
Events
Envelope
Every published message is the platform's canonical camelCase envelope (packages/go/domain/event_envelope.go): eventId, eventType, occurredAt, partyLocator, correlation and causation ids, session lineage, the payload, and event-carried state. The outbox worker (internal/outbox/worker.go) stamps correlationId and lineage from the OTel trace context captured at enqueue time and forwards the state snapshot recorded by EnqueueState emit sites. See the Kafka Event Catalog for the envelope and the Event Registry for per-type contracts.
Publishes
All billing events go to a single Kafka topic: billing.events. The event verb is an eventType field inside the envelope, not a topic name. Emit sites that hold the freshly written entity attach it as a named state subject via EnqueueState; sites that only hold a pre-transition struct or a query row publish payload-only events.
| eventType | Emitted when | State subjects |
|---|---|---|
charge.created | The settle paths write the charge row (SettleFunnelPayment, SettleDirectPurchase) | charge |
charge.voided | VoidCharge marks a charge VOID | none |
invoice.finalised | FinaliseInvoice moves a DRAFT invoice to FINALISED | none |
invoice.paid | An invoice reaches PAID: the settle paths raise-and-pay in one tx, or RecordPayment covers the total | invoice (settle paths only) |
invoice.void | VoidInvoice marks an invoice VOID | none |
invoice.overdue | Lapse job: a FINALISED invoice is past due and its policy is lapsed via Enrollment | none |
invoice.delinquent | Delinquency job: an overdue invoice passes the grace window | none |
invoice.lapsed | Delinquency job: a delinquent invoice passes 2x grace | none |
account.suspended | Same tx as invoice.lapsed, keyed on the billing account; reason is always non_payment | none |
payment.settled | The Stripe webhook settle paths record the SETTLED card payment | payment |
payment.received | RecordPayment records a manual/API payment against a FINALISED invoice | payment |
payment.voided | VoidPayment voids a SETTLED payment and reverses the ledger entry | none |
payment.refunded | Same tx as payment.voided: the reversal ledger entry is the refund transition | none |
adjustment.created | CreateAdjustment writes a PENDING adjustment | adjustment |
adjustment.applied | ApplyAdjustment moves PENDING to APPLIED with its ledger entry | none |
adjustment.reversed | ReverseAdjustment moves APPLIED to REVERSED with an opposing ledger entry | none |
installment.created | CreateSchedule writes an ACTIVE installment schedule | schedule |
installment.cancelled | CancelSchedule moves a schedule to CANCELLED | none |
quote.payment_initiated | D2C checkout starts (one-off PaymentIntent or recurring Subscription handler) | paymentIntent or subscription |
quote.payment_settled | MarkQuotePaid records the settled quote payment | quotePayment |
quote.payment_failed | MarkQuoteFailed records the failed quote payment | quotePayment |
Contracts for every type (payload schema, state and lineage requirements, producers and consumers as code refs, golden examples) live in the event registry at packages/go/domain/eventregistry/registry/<eventType>/.
Consumes
Two topics, dispatched on eventType:
| Topic (env) | eventType | Action taken |
|---|---|---|
enrollment-events (KAFKA_ENROLLMENT_TOPIC) | policy.issued | Employer enrolments (event carries a schemeLocator): resolve scheme → employer billing account, price the member's monthly premium from the policy-admin product catalogue by planTier, and append a per-member PREMIUM charge + line item to the employer's consolidated DRAFT invoice (get-or-create). Non-scheme events are dropped. The charge carries no term (TermID nil) and the invoice total is recomputed from line items. |
policy.renewed | Legacy per-term flow: EvaluateBillingRules charges + a draft invoice for the renewal term | |
policy.reinstated | Same legacy per-term flow, on reinstatement | |
policy.endorsed | Create a premium-delta charge and add it to the draft invoice | |
policy.cancelled | Void all PENDING charges for the policy (INVOICED/PAID untouched) | |
claims.events (KAFKA_CLAIMS_TOPIC) | claim.approved | Create CLAIM charges from the approved claim's allowed lines |
There is no enrollment.policy.activated topic or event in the codebase. First-invoice generation is triggered by policy.issued.
Scheduled jobs
| Job | Wired in main.go | What it does |
|---|---|---|
| Lapse | Yes | Polls FINALISED invoices past due_date, calls Enrollment LapsePolicy, emits invoice.overdue. Interval LAPSE_JOB_INTERVAL (default 1h). |
| Delinquency | Constructed; background ticker gated by BILLING_DELINQUENCY_JOB=on (off by default) | Marks overdue invoices DELINQUENT (past grace) and LAPSED (past 2x grace), emitting invoice.delinquent / invoice.lapsed / account.suspended. The admin-only POST /invoices/{locator}/delinquency-tick route runs the state machine for one invoice on demand (lapse-tick is the lapse job's sibling). |
There is no recurring billing-cycle invoice generator. Renewal invoices come from the enrollment policy.renewed event, not a billing-side cron.
Dependencies
| Service | How used |
|---|---|
| Enrollment | GetPolicy / GetTerm during event handling; LapsePolicy from the lapse job |
| Policy Admin | EvaluateBillingRules(policy, term) on the legacy per-term flow; the product catalogue (premiumPerMemberMonthly) prices the per-member monthly premium on policy.issued (static fallback table when unreachable; an unknown tier raises no charge rather than a silent zero) |
| Group Scheme | Resolves scheme → employer party when ensuring the employer billing account on policy.issued |
| Stripe | PaymentIntents for the funnel, webhook settlement, saved-card details |
| Claims | GetClaim during claim.approved handling, to read the approved lines. Not called when serving EOB. |
Key design decisions
Ledger. Every payment and adjustment posts a LedgerEntry with a direction (DEBIT owed to Olly, CREDIT owed by Olly) and a polymorphic reference_id + reference_type (no DB FK). Charge debits are not yet posted (the CHARGE entry type is defined but unwritten), so the ledger currently records payments and adjustments only. Reversals are new opposing entries; the original is never mutated. See the domain page for the ledger model.
Transactional outbox. FinaliseInvoice, RecordPayment, VoidPayment, ApplyAdjustment, and ReverseAdjustment wrap the business write and the outbox insert in one DB transaction. Either both commit or neither does. The outbox worker then publishes to billing.events.
Charge-driven invoicing. Charges are created PENDING by the projection handlers and accumulate on a per-account DRAFT invoice (getOrCreateDraftInvoice). Finalising the invoice flips its charges to INVOICED and stamps a due date (30 days out). A payment that brings settled ≥ total marks the invoice PAID and its charges PAID.
EOB is computed from billing's own charges. An EOB for a claim is derived from the CLAIM-category charges that the claim.approved projection created earlier, filtered by claim_locator. The EOB read path does not call the Claims service; TotalBilled is the sum of charge amounts and TotalPaid the sum of those marked PAID. This keeps member financial summaries in one service.
Invariants
- All billing events publish to the one
billing.eventstopic; the verb is the envelopeeventType. - Invoice lifecycle is
DRAFT → FINALISED → PAID(orVOID), withDELINQUENT/LAPSEDset by the delinquency job. There is noOPENstatus. - A payment is recorded only against a
FINALISEDinvoice; the invoice becomesPAIDonceΣ SETTLED payments ≥ total_amount. - Charge lifecycle is
PENDING → INVOICED → PAID, withVOID.policy.cancelledvoids only PENDING charges. - Every payment and adjustment posts a balancing ledger entry; reversals add opposing entries rather than mutating. Charge debits are not yet posted to the ledger.
Caveats
- Autopay is not live. The table exists (migration
0014) but there is no autopay handler, job, or collection logic. Installment routes ARE wired (see Stripe payments section above). - The delinquency background ticker is off by default.
DelinquencyJobis constructed and the per-invoicedelinquency-tickroute works, but the automatic sweep only runs withBILLING_DELINQUENCY_JOB=on. Without it,DELINQUENT/LAPSED/account.suspendedare produced on demand only; the always-on lapse job (overdue to policy lapse) is the one automatic mechanism. - The
policy.issuedprojection is at-least-once, not charge-deduped. Idempotency is offset/checkpoint-based only; a Kafka redelivery would append a duplicate premium charge to the employer's draft. The Stripe settlement path, by contrast, is unique-index idempotent. - Nothing finalises or collects the monthly drafts yet. Premium drafts accumulate per employer; the recurring billing cycle and reconciliation against the onboarding seat-block payment are pending a billing-model decision (#1438, #1439).
- Statuses are conventions, not enums. All status / direction / method / frequency columns are free
TEXTwith no DB CHECK; the application validates.ChargeCategory(PREMIUM/TAX/FEE/CLAIM) is the one billing enum defined inpackages/go/domain/enums.go. - Single currency. Amounts default to
GBP; this is a single-currency domain today.
