Skip to content
Updated Jul 12, 2026

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

TablePurpose
chargesIndividual charges (PREMIUM / TAX / FEE / CLAIM) per coverage period
invoicesGrouped invoices; lifecycle DRAFT → FINALISED → PAID (or VOID), plus DELINQUENT / LAPSED
invoice_line_itemsLine items, each referencing one charge with its amount copied across
paymentsPayment transactions; settle exactly one invoice
adjustmentsManual and automated credits/debits
ledger_entriesDouble-entry ledger of all financial movements
installment_schedulesSplit a total into N invoices (table exists; service not wired, see caveats)
autopay_preferencesPer-policy autopay flag and method (table exists; no API, see caveats)
outboxTransactional outbox for reliable Kafka publishing
projection_checkpointsConsumer 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.

MethodPathAuthDescription
GET/chargesJWTList charges (filterable)
GET/charges/{locator}JWTGet charge by locator
PATCH/charges/{locator}/voidJWTVoid a charge
GET/invoicesJWTList invoices (filterable)
GET/invoices/{locator}JWTGet invoice by locator
PATCH/invoices/{locator}/finaliseJWTFinalise a DRAFT invoice (sets total, due date)
PATCH/invoices/{locator}/voidJWTVoid an invoice
POST/invoices/{locator}/number/generateJWTGenerate an invoice display number
POST/paymentsJWTRecord a payment against a FINALISED invoice
GET/paymentsJWTList payments (filterable)
GET/payments/{locator}JWTGet payment by locator
PATCH/payments/{locator}/voidJWTVoid a SETTLED payment
POST/adjustmentsJWTCreate a billing adjustment
GET/adjustmentsJWTList adjustments
GET/adjustments/{locator}JWTGet adjustment by locator
PATCH/adjustments/{locator}/applyJWTApply a PENDING adjustment
PATCH/adjustments/{locator}/reverseJWTReverse an APPLIED adjustment
GET/ledgerJWTList ledger entries
GET/ledger/{locator}JWTGet a ledger entry
GET/eobJWTList EOB summaries (grouped by claim)
GET/eob/{claimLocator}JWTGet EOB for a claim
GET/payment-methodJWTSaved card on file: {onFile, brand?, last4?, expMonth?, expYear?}; no card is 200 {onFile: false}
POST/onboarding/payment-intentsNone (pre-account funnel)Create a Stripe PaymentIntent for the quote funnel (amountPence = pricePerMember x headcount, card saved for off-session reuse)
POST/stripe/webhookStripe-SignatureStripe webhook receiver (see below)
GET/internal/invoices/{locator}InternalFetch 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.

eventTypeEmitted whenState subjects
charge.createdThe settle paths write the charge row (SettleFunnelPayment, SettleDirectPurchase)charge
charge.voidedVoidCharge marks a charge VOIDnone
invoice.finalisedFinaliseInvoice moves a DRAFT invoice to FINALISEDnone
invoice.paidAn invoice reaches PAID: the settle paths raise-and-pay in one tx, or RecordPayment covers the totalinvoice (settle paths only)
invoice.voidVoidInvoice marks an invoice VOIDnone
invoice.overdueLapse job: a FINALISED invoice is past due and its policy is lapsed via Enrollmentnone
invoice.delinquentDelinquency job: an overdue invoice passes the grace windownone
invoice.lapsedDelinquency job: a delinquent invoice passes 2x gracenone
account.suspendedSame tx as invoice.lapsed, keyed on the billing account; reason is always non_paymentnone
payment.settledThe Stripe webhook settle paths record the SETTLED card paymentpayment
payment.receivedRecordPayment records a manual/API payment against a FINALISED invoicepayment
payment.voidedVoidPayment voids a SETTLED payment and reverses the ledger entrynone
payment.refundedSame tx as payment.voided: the reversal ledger entry is the refund transitionnone
adjustment.createdCreateAdjustment writes a PENDING adjustmentadjustment
adjustment.appliedApplyAdjustment moves PENDING to APPLIED with its ledger entrynone
adjustment.reversedReverseAdjustment moves APPLIED to REVERSED with an opposing ledger entrynone
installment.createdCreateSchedule writes an ACTIVE installment scheduleschedule
installment.cancelledCancelSchedule moves a schedule to CANCELLEDnone
quote.payment_initiatedD2C checkout starts (one-off PaymentIntent or recurring Subscription handler)paymentIntent or subscription
quote.payment_settledMarkQuotePaid records the settled quote paymentquotePayment
quote.payment_failedMarkQuoteFailed records the failed quote paymentquotePayment

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)eventTypeAction taken
enrollment-events (KAFKA_ENROLLMENT_TOPIC)policy.issuedEmployer 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.renewedLegacy per-term flow: EvaluateBillingRules charges + a draft invoice for the renewal term
policy.reinstatedSame legacy per-term flow, on reinstatement
policy.endorsedCreate a premium-delta charge and add it to the draft invoice
policy.cancelledVoid all PENDING charges for the policy (INVOICED/PAID untouched)
claims.events (KAFKA_CLAIMS_TOPIC)claim.approvedCreate 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

JobWired in main.goWhat it does
LapseYesPolls FINALISED invoices past due_date, calls Enrollment LapsePolicy, emits invoice.overdue. Interval LAPSE_JOB_INTERVAL (default 1h).
DelinquencyConstructed; 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

ServiceHow used
EnrollmentGetPolicy / GetTerm during event handling; LapsePolicy from the lapse job
Policy AdminEvaluateBillingRules(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 SchemeResolves scheme → employer party when ensuring the employer billing account on policy.issued
StripePaymentIntents for the funnel, webhook settlement, saved-card details
ClaimsGetClaim 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.events topic; the verb is the envelope eventType.
  • Invoice lifecycle is DRAFT → FINALISED → PAID (or VOID), with DELINQUENT / LAPSED set by the delinquency job. There is no OPEN status.
  • A payment is recorded only against a FINALISED invoice; the invoice becomes PAID once Σ SETTLED payments ≥ total_amount.
  • Charge lifecycle is PENDING → INVOICED → PAID, with VOID. policy.cancelled voids 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. DelinquencyJob is constructed and the per-invoice delinquency-tick route works, but the automatic sweep only runs with BILLING_DELINQUENCY_JOB=on. Without it, DELINQUENT / LAPSED / account.suspended are produced on demand only; the always-on lapse job (overdue to policy lapse) is the one automatic mechanism.
  • The policy.issued projection 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 TEXT with no DB CHECK; the application validates. ChargeCategory (PREMIUM/TAX/FEE/CLAIM) is the one billing enum defined in packages/go/domain/enums.go.
  • Single currency. Amounts default to GBP; this is a single-currency domain today.

Olly Health Insurance Platform