Skip to content
Updated Aug 22, 2026

Billing account & charges

Schema deep-dive · living document · #8 in the reading sequence

Tablesbilling.accounts, billing.charges[1][2]
Owner servicebilling (sole writer)
LocatorsACC- (accounts) · CHG- (charges)
Last updated2026-08-19
CompanionERD story, slide 8 · real policy JSON · previous: Claim

1. Scope and usage

The billing account is the payer of record: one row per employer, keyed by the employer's party locator, holding the Stripe customer and the saved card. A charge is one atomic money fact accrued against that account - one member's monthly premium, one approved claim line to reimburse. Charges are the accrual side of billing; what happens to them (invoicing, settlement, ledger) is the next page.

"A premium is the amount paid to an insurer in consideration of the insurer agreeing to cover the risk" - the charge row is that amount, made a first-class record: who owes it (account_id), for whom (member_locator), for what cover (policy_id), and for which month (charge_date).

The account exists because before it, nothing did: enrollment minted a synthetic per-member UUID as account_id and no row stood behind it, so an employer could never be invoiced and no card could be held. The migration that introduced the table says so in full[1] - and the old lineage is still visible in the data (§9).

2. Boundaries and relationships

An account / charge is not…That concern lives inJoin
the employerpolicy_admin.parties (ORGANISATION). org_locator is its PTY- locator under an alias - the migration comment states it verbatim: "Employer PARTY locator (PTY-…), the org_locator claim. Unique: one billing account per employer."[1]locator
policy_admin.accountsa different table: policy-admin's account shell (BillingLevel ACCOUNT | POLICY)[3]. Same word, different schema; billing's payer is billing.accountsnone
the schemegroup_scheme.schemes; the account carries scheme_locator as a pointer, and billing resolves scheme → employer through group-scheme's API[8]locator
the cardStripe. No PAN is stored - only the customer / payment-method handles plus brand/last4/expiry for display[1]stripe_customer_id
the invoicebilling.invoices; a charge becomes a line item on the employer's consolidated draft invoice[6]invoice_line_items.charge_id
a pricepricing resolves at accrual time from policy-admin's product catalogue (premiumPerMemberMonthly by tier); the charge stores the resolved amount[5]-
a ledger entrybilling.ledger_entries - and note the ledger records settlements only, never charge accruals (next page's wart)-

3. Structure

DDL[1][2] · Go models[4][7]

accounts

FieldTypeReqNotes
iduuidPK. Deterministic UUIDv5 of the org locator - see below
locatortextUNIQUE. ACC-YYYY-NNNNNN
org_locatortextUNIQUE. The employer's PTY- locator = the org_locator JWT claim
scheme_locatortextThe scheme this account bills for; indexed
currencychar(3)Default 'GBP'
statustextDefault 'ACTIVE'; convention (ACTIVE | CLOSED declared in Go, no CHECK)
stripe_customer_id, stripe_payment_method_idtextStripe handles; customer id indexed
card_brand, card_last4, card_exp_month, card_exp_yeartext/intDisplay metadata only - never a PAN
created_at, updated_attimestamptzBookkeeping

charges

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE CHG-
policy_id, term_iduuidNullable (originally NOT NULL; relaxed for scheme-level charges[1])
claim_id, claim_locatoruuid / textSet on CLAIM charges; locator stored to spare EOB list queries an N+1[7]
account_iduuidNo FK - and two lineages live under it (§9)
categorytextPREMIUM | CLAIM live; Go declares TAX, FEE too[9]
amount / currencynumeric(14,4) / char(3)The platform-wide money shape
statustextDefault 'PENDING'; then INVOICED | PAID | VOID (convention)
charge_datedateWhich day the charge accrued for
scheme_locator, member_locatortextPer-member traceability on the consolidated invoice; both indexed[1]
descriptiontextHuman-readable line ("Monthly premium - 1 member (STANDARD)")

Field-by-field: what and why

accounts.id - deterministic, not random. The id is uuidv5("billing-account:" + orgLocator)[8]. Why? Two independent write paths create accounts - the Stripe webhook and the policy.issued projection - and neither knows whether the other ran first. Deterministic ids mean both compute the same id, and the insert is an ON CONFLICT (org_locator) DO NOTHING upsert[10]: no fan-out of account ids, no ordering requirement.

org_locator - the anchor to Party, NOT NULL UNIQUE: one billing account per employer. It is the same value the employer app carries in its org_locator JWT claim, which is what lets the billing API scope every read to "your own account" without a lookup. A locator, not an FK: parties live in another database.

The Stripe fields - populated by the payment_intent.succeeded webhook after the funnel payment; read back by GET /billing/payment-method for the dashboard's card tile[4]. The brand/last4/expiry are denormalised from the PaymentMethod so rendering "Visa ···· 4242" never calls Stripe. The Stripe customer is created at payment-intent time with schemeLocator in its metadata[11], which is the thread the webhook later pulls to find the employer.

charges.category - the money vocabulary, and the cheapest extension point on the page: a category is data in a text column, so a new money kind is a writer, never a migration. Live data is two values (2 085 PREMIUM, 4 CLAIM rows): premiums flow employer-ward, claim reimbursements flow member-ward, and both ride the same rail. TAX and FEE are declared in Go[9] and reserved for when those lines are charged. CONTRIBUTION - the member's share - is declared on the product version's charge_types: ["PREMIUM", "CONTRIBUTION"] (Product & catalogue) and activates when the collection decision lands (§8.7); today contribution_after_included is displayed rather than collected.

policy_id / term_id nullable - three charge shapes coexist, and the nullability is the discriminator:

Shapepolicy_idterm_idWritten by
Member monthly premiumsetNULL - the policy.issued event carries no term, and a premium charge does not need one[6]Kafka projection
Scheme-level seat subscription (quote funnel)NULLNULL - N seats at once, no single policy behind it[12]Stripe webhook
Claim reimbursementsetsetKafka projection (claim.approved)[6]

Live counts agree: 78 charges with NULL policy_id (the funnel seat subscriptions), 635 with NULL term_id (seat subscriptions + member premiums). The lapse job INNER JOINs enrollment.policies on policy_id, so a scheme-level charge is correctly never a lapse candidate[13].

scheme_locator / member_locator - what makes the consolidated employer invoice auditable: every member's premium is its own charge line, attributable back to the scheme and the member party it was raised for[7]. "Whose £19.50 is this?" is one indexed predicate.

Locator minting - billing does not use the shared per-prefix Postgres sequence (ADR-1164-05) that Party and Scheme use. It has its own in-process counter, seeded at boot from the max existing locator per prefix[14] - sufficient for a single instance, a known divergence to reconcile before running two.

4. Invariants

InvariantEnforced by
locator unique (both tables)DB unique constraints[1][2]
One account per employerDB UNIQUE on org_locator[1]
Concurrent account creation converges on one rowApplication: deterministic UUIDv5 id + ON CONFLICT DO NOTHING upsert[8][10]
category, status vocabulariesNothing in the DB - Go constants and convention only[9]
charges.account_id points at a real accountNothing - no FK, and for most legacy rows it genuinely does not (§9)
An unknown plan never prices to £0Application: pricing returns not-found rather than a silent zero - "A £0 premium is how employers ended up with £0 invoices"[5]
Failed event handling never loses a chargeApplication: the consumer does not commit the Kafka offset on handler error - retried on restart[15]
Row changes captured to CDCDebezium publication dbz_billing (live \d)

5. Lifecycle

A charge's own lifecycle is PENDING → INVOICED → PAID (or → VOID on policy cancellation[6]), driven entirely by what the invoice does - the next page covers those transitions. What belongs here is how a charge comes to exist: the accrual pipeline.

Hops 1-2 used to be a topic naming split-brain: enrollment's outbox rows were enqueued with topic enrollment.events (dot), the outbox worker ignored the row's topic column and published to the producer's single configured topic, and the hyphenated enrollment-events was what billing subscribed to. The pipeline worked and the row-level metadata lied.

Both halves are now closed. The worker publishes to the topic on the row rather than a producer-wide default, and every consumer of enrollment events defaults to the dual list enrollment-events,enrollment.events[19], so a rename cannot make a consumer deaf mid-flight. The dual list is the code default rather than compose-only configuration, which is the part that matters: the previous fix lived in a compose file on dev-2 and would have been lost on the next deploy from a clean checkout.

Live proof it works: billing.projection_checkpoints holds (enrollment-events, 0) at offset 1834, last processed 2026-08-17, alongside (claims.events, 0) at 2192.

Two more design points ride the diagram:

  • Pricing has one owner and a dual lineage to survive. The resolver reads policy-admin's catalogue (the price the employer was quoted); a static table is a last-resort fallback kept equal to it. The preferred billing-ruleset path switches on as soon as a policy carries a real product version with a populated policy_schema; Flow-0 policies carry either a synthetic version (uuid.NewSHA1("flow0-product:" + planTier), which policy-admin 404s) or a real one whose policy_schema is empty, so the catalogue path prices them - the pricing package comment documents the three-way order verbatim[5]. Both plan-name vocabularies (base|recommended|unlimited from the funnel, BASE|STANDARD|UNLIMITED from enrollment) resolve to the same three tiers[5].
  • The event's account is ignored on purpose. policy.issued carries no usable account (the policy's account_id is enrollment's synthetic per-member UUIDv5, "flow0-account:" + partyLocator, minted under a TODO(#1164)[20]). The handler resolves scheme → employer → real account instead[6]. The claim path (HandleClaimApproved) still trusts the event's account_id - which is why live CLAIM charges sit on synthetic accounts (§9).

6. Populated example

The real chain behind one member's premium, from the live DB (structure and values exact; parties are the seeded demo estate):

json
{
  "locator": "ACC-2026-000630",
  "org_locator": "PTY-2026-000738",
  "scheme_locator": "SCH-2026-001214",
  "currency": "GBP",
  "status": "ACTIVE",
  "stripe_customer_id": "",
  "stripe_payment_method_id": "",
  "card_brand": "", "card_last4": "",
  "card_exp_month": 0, "card_exp_year": 0
}
json
{
  "locator": "CHG-2026-000628",
  "policy_id": "39ac65a4-4782-4e00-83e3-f9ed8329007d",
  "term_id": null,
  "claim_id": null, "claim_locator": "",
  "account_id": "1f25a1c1-9d39-5f77-b07d-ad8aab985e93",
  "category": "PREMIUM",
  "amount": "19.5000", "currency": "GBP",
  "status": "PENDING",
  "charge_date": "2026-08-05",
  "scheme_locator": "SCH-2026-001214",
  "member_locator": "PTY-2026-000741",
  "description": "Monthly premium - 1 member (STANDARD)"
}
json
{
  "locator": "CHG-2026-000282",
  "policy_id": "…set…", "term_id": "…set…",
  "claim_locator": "CLM-2026-002676",
  "account_id": "51ede540-34cb-5139-ba3a-fd78d44c42c3",
  "category": "CLAIM",
  "amount": "1.0000", "currency": "GBP",
  "status": "PENDING",
  "charge_date": "2026-07-12",
  "scheme_locator": "", "member_locator": "",
  "description": "Claim reimbursement: GP video consultation"
}

Field-by-field, what each value does downstream:

The account (created by the policy.issued projection via EnsureForScheme):

KeyRead byWhat actually happens
org_locator: PTY-2026-000738every billing list endpointrequests with this org_locator claim see exactly this account's charges and invoices; anyone else gets nothing
scheme_locator: SCH-2026-001214draft-invoice creation[6]denormalised onto the invoice so the employer dashboard finds it with one predicate
empty Stripe fieldspayment-method tilethis employer enrolled members but has not paid through the funnel yet - no card on file. Accounts that did pay carry cus_… / pm_… / visa / 4242 / 12/2034 (real example: ACC-2026-000048)

The PREMIUM charge (£19.50 = the STANDARD tier's premium_per_member_monthly in the catalogue - the same number the plan-picker quoted, resolved by tier[5]):

KeyRead byWhat actually happens
account_id (uuidv5)invoice consolidationjoins to ACC-2026-000630; this member's line lands on the employer's one draft invoice - live, INV-2026-000236 carries this charge plus two siblings (CHG-…626, …627), total £58.50
term_id: nulllapse job[13]nothing breaks: the event carried no term and none was fabricated
member_locator: PTY-2026-000741employer dashboard, GET /internal/members/{party}/chargesthe per-member line on the consolidated invoice; also the member portal's "what was billed for me"
status: PENDINGinvoice finaliseflips to INVOICED when the draft invoice finalises, PAID when it settles

The CLAIM charge - the payout rail. When claims approves, each approved line with a positive allowed amount becomes a CLAIM charge[6], and the EOB (Explanation of Benefits) endpoint aggregates them by claim_locator into billed-vs-paid totals[21]. Note its account_id: no billing.accounts row matches it - the claim.approved event still carries enrollment's synthetic per-member account id, and the handler uses it as-is. The payout rail works; its account attribution is legacy-lineage (§9).

7. Who references an account / a charge

WhereColumn / mechanismNature
billing.invoicesaccount_id (+ denormalised org_locator, scheme_locator)in-service uuid, no FK
billing.invoice_line_itemscharge_idthe charge-to-invoice join, no FK
billing.payments, billing.ledger_entries, billing.installment_schedulesaccount_idin-service uuid, no FK
group_scheme.schemesaccount_idstaged for the group-contract elevation (#219); NULL today
EOB endpoints (/billing/eob/{claimLocator})claim_locator on CLAIM chargesclaims-world join, locator not FK[21]
employer & member apps, web-admin, MCP toolsGET /billing/charges… scoped by org_locator claimAPI, not schema

8. Design determinations

  1. One billing account per employer, keyed by the party locator - the org_locator claim is the party locator under an alias, so the JWT, the account and the invoice all pivot on the same value. Flow-0 break #3 fix, #1164.
  2. Deterministic account identity - UUIDv5 + upsert lets two independent write paths (webhook, projection) converge without coordination. (§3)
  3. The catalogue owns the price - billing charges what the employer was quoted; the static table is the fallback, and the ruleset path is the preferred resolver that takes over once product versions carry a policy_schema. #1164. (§5)
  4. Charge granularity = one member, one month - what makes the consolidated employer invoice per-member auditable via scheme_locator/member_locator. (§3)
  5. Claim payouts reuse the charge rail - a reimbursement is a CLAIM charge, not a parallel money model; EOB is an aggregation over it. (§6)
  6. Scheme-level charges get NULL policy/term rather than fabricated references - the funnel's seat subscription relaxed the original NOT NULLs. (§3)
  7. Recurring collection is deliberately absent - accrual exists, the monthly cycle job does not yet: model decision #1438, cycle job #1439.

9. Caveats and extensibility

Group and individual: the design is payer-agnostic. A charge points at an account; nothing about the chain requires the payer to be an employer. An individual policyholder is an account whose org_locator holds their own party locator and whose scheme_locator is NULL - the same accrual pipeline, pricing and invoice consolidation apply unchanged (the projection no-ops schemeless policies today, and that branch is the one place to open[6]). The group/individual fork costs a NULL column here, exactly as it does on Scheme. Employer-paid healthcare adds its own commercial mechanics on top .

Where to extend, and what it costs:

When we need …What to addWhere
A new money kind - tax, admin fee, excess, adjustmentA writer that inserts the charge with the new category. The column is text, TAX and FEE are already declared, and invoice consolidation, EOB and the status machine are category-agnostic, so no migration and no downstream changeChargeCategory constants[9]; the writer sits beside memberPremium in projection/handlers.go[6]
To collect the member's share (CONTRIBUTION)The collection decision, then a writer. The product version already declares the charge type and the charge row already carries member_locator, so the accrual, invoice line and per-member attribution are in placeModel decision #1438; charges.category + member_locator[2]
Direct-to-consumer payersAn account whose org_locator holds the member's own party locator and whose scheme_locator is NULL, and the schemeless branch of the policy projection opened. Pricing, accrual, consolidation and the org_locator read-scope are unchangedbilling.accounts[1]; the schemeless no-op in HandlePolicyIssued[6]
A new upstream event to accrue from (a care, provider or endorsement event)A handler beside the existing ones plus the topic on the consumer's list. Checkpointing, no-commit-on-error retry and the dual-spelling topic list come with itprojection/handlers.go[6]; topic list[19]; consumer loop[15]
Claim payouts to attribute to the real payerResolve the account from the claim's scheme or member rather than trusting the event's account_id, reusing EnsureForScheme as the premium path doesHandleClaimApproved[6]account.EnsureForScheme[8]
A second market or currencyA market_profiles entry and the market set on the resolver. currency char(3) is on both tables; "GBP" is the constant at each write site to replaceResolver.Market[5]; currency columns[1][2]
Per-product pricing beyond tierA populated policy_schema on the product version; the preferred ruleset resolver then takes over from the catalogue path with no billing changepricing.go resolver order[5]
Recurring monthly collectionThe cycle job. Accrual, the PENDING → INVOICED → PAID transitions and the lapse finder already exist; the schedule is the remaining pieceModel decision #1438, cycle job #1439

Known defects - the fix, and where it goes:

  • account_id is dual-lineage and mostly dangling. Live: 2 089 charges reference 1 632 distinct account ids, but only 180 accounts rows exist and only 635 charges join to one. The rest carry enrollment's synthetic per-member UUIDv5s (or pre-accounts legacy ids) - harmless to the consolidated-invoice path (which never reads them) but a trap for anyone joining charges → accounts and expecting completeness. Same shape on invoices: 1 694 rows, 241 join. Fix, in two parts: resolve the account on the claim path (extension table above) so no new dangling rows are written, then a backfill mapping synthetic ids to the real account by member_locator before any FK is added.
  • The claim path writes the legacy lineage. HandleClaimApproved uses the event's synthetic account_id as-is, which is where the dangling rows come from; the handlers' own comment scopes those flows out[6]. Fix: the EnsureForScheme resolution the premium path already performs.
  • No DB enforcement of any vocabulary - category and both status columns are bare text, so a typo persists silently. Fix: CHECK constraints in a new services/billing/migrations step, kept equal to the Go constants[9]. Note this narrows the free-category extension row above: add the constant and the CHECK together.
  • Locator minting is in-process (max-seeded at boot), which collides if a second billing instance runs; seeded fixture locators (CHG-EOB-0001) also live in the namespace. Fix: mint through the shared per-prefix DB sequence (ADR-1164-05) that Party and Scheme use[14], before scaling out.
  • Topic naming is inconsistent, and no longer lossy - both spellings exist on the broker and consumers subscribe to both (§5). document-service's subscription was corrected from claims-events (hyphen), which the claims outbox never published to, to claims.events[22]).

References

Code links are pinned to commit 8329d7b on main (2026-08-19); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. billing/migrations/0018_create_accounts.sql - accounts DDL; break history L2-L11; org_locator comment L25-L27; charge locator columns L55-L58; policy/term NULL relaxation L61-L67; webhook idempotency index L69-L81
  2. billing/migrations/0002_create_charges.sql - charges DDL (policy_id/term_id originally NOT NULL)
  3. packages/go/domain/accounts.go#L10 - the other accounts table (policy_admin.accounts)
  4. packages/go/domain/billing.go#L10 - BillingAccount model + card-denormalisation comment
  5. billing/internal/pricing/pricing.go#L1 - one price owner, fallback table, dual-lineage product versions; aliases L46-L59; no-silent-zero L83-L110
  6. billing/internal/projection/handlers.go#L121 - HandlePolicyIssued (three documented breaks + fixes); memberPremium L228-L264; HandleClaimApproved L375-L434; getOrCreateDraftInvoice L496-L527; legacy account path L529-L555
  7. packages/go/domain/charges.go#L10 - Charge model; nullability comment L13-L19; traceability comment L31-L36
  8. billing/internal/service/account.go#L15 - accountIDFor UUIDv5; EnsureForScheme L54-L67; Ensure L72-L98
  9. packages/go/domain/enums.go#L33 - ChargeCategory: PREMIUM, TAX, FEE, CLAIM
  10. billing/internal/repository/gorm_accounts.go#L59 - Upsert ON CONFLICT (org_locator) DO NOTHING
  11. billing/internal/handler/payment_intents.go#L57 - Stripe customer + PaymentIntent metadata (schemeLocator)
  12. billing/internal/service/onboarding.go#L164 - seat-subscription charge, PolicyID/TermID nil
  13. billing/internal/job/lapse.go#L29 - overdue finder INNER JOIN on policy_id
  14. billing/internal/service/locator.go#L15 - in-process counter; boot seeding at cmd/server/main.go#L94
  15. billing/internal/projection/consumer.go#L67 - no-commit-on-error + checkpoint save; event dispatch L81-L96
  16. enrollment/internal/service/policy.go#L618 - policy.issued enqueued with topic string enrollment.events
  17. enrollment/internal/outbox/worker.go#L48 - publish now uses the row's topic via PublishToTopic; producer bound at cmd/server/main.go#L107
  18. enrollment/internal/config/config.go#L54 - KAFKA_TOPIC default enrollment-events
  19. billing/internal/config/config.go#L92 - billing subscribes the dual list enrollment-events,enrollment.events plus claims.events
  20. enrollment/internal/service/policy.go#L503 - TODO(#1164) synthetic "flow0-account:" + partyLocator UUIDv5
  21. billing/internal/service/eob.go#L30 - EOB aggregation over CLAIM charges by claim_locator
  22. document-service/internal/config/config.go#L54 - subscribes claims-events (hyphen) where billing reads claims.events (dot)

Live-schema facts (constraint list, category/status distributions, NULL counts, account-join counts, checkpoint offsets, Debezium publication) come from psql -h 10.0.1.2 -U olly -d billing · \d billing.accounts, \d billing.charges, select category, count(*) from billing.charges group by 1, select count(*) from billing.charges c join billing.accounts a on a.id = c.account_id, select topic, partition_id, "offset" from billing.projection_checkpoints, 2026-08-18.

Olly Health Insurance Platform