Billing account & charges
Schema deep-dive · living document · #8 in the reading sequence
| Tables | billing.accounts, billing.charges[1][2] |
| Owner service | billing (sole writer) |
| Locators | ACC- (accounts) · CHG- (charges) |
| Last updated | 2026-08-19 |
| Companion | ERD 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 in | Join |
|---|---|---|
| the employer | policy_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.accounts | a different table: policy-admin's account shell (BillingLevel ACCOUNT | POLICY)[3]. Same word, different schema; billing's payer is billing.accounts | none |
| the scheme | group_scheme.schemes; the account carries scheme_locator as a pointer, and billing resolves scheme → employer through group-scheme's API[8] | locator |
| the card | Stripe. No PAN is stored - only the customer / payment-method handles plus brand/last4/expiry for display[1] | stripe_customer_id |
| the invoice | billing.invoices; a charge becomes a line item on the employer's consolidated draft invoice[6] | invoice_line_items.charge_id |
| a price | pricing resolves at accrual time from policy-admin's product catalogue (premiumPerMemberMonthly by tier); the charge stores the resolved amount[5] | - |
| a ledger entry | billing.ledger_entries - and note the ledger records settlements only, never charge accruals (next page's wart) | - |
3. Structure
accounts
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK. Deterministic UUIDv5 of the org locator - see below |
locator | text | ✓ | UNIQUE. ACC-YYYY-NNNNNN |
org_locator | text | ✓ | UNIQUE. The employer's PTY- locator = the org_locator JWT claim |
scheme_locator | text | The scheme this account bills for; indexed | |
currency | char(3) | ✓ | Default 'GBP' |
status | text | ✓ | Default 'ACTIVE'; convention (ACTIVE | CLOSED declared in Go, no CHECK) |
stripe_customer_id, stripe_payment_method_id | text | Stripe handles; customer id indexed | |
card_brand, card_last4, card_exp_month, card_exp_year | text/int | Display metadata only - never a PAN | |
created_at, updated_at | timestamptz | ✓ | Bookkeeping |
charges
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE CHG- |
policy_id, term_id | uuid | Nullable (originally NOT NULL; relaxed for scheme-level charges[1]) | |
claim_id, claim_locator | uuid / text | Set on CLAIM charges; locator stored to spare EOB list queries an N+1[7] | |
account_id | uuid | ✓ | No FK - and two lineages live under it (§9) |
category | text | ✓ | PREMIUM | CLAIM live; Go declares TAX, FEE too[9] |
amount / currency | numeric(14,4) / char(3) | ✓ | The platform-wide money shape |
status | text | ✓ | Default 'PENDING'; then INVOICED | PAID | VOID (convention) |
charge_date | date | ✓ | Which day the charge accrued for |
scheme_locator, member_locator | text | Per-member traceability on the consolidated invoice; both indexed[1] | |
description | text | Human-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:
| Shape | policy_id | term_id | Written by |
|---|---|---|---|
| Member monthly premium | set | NULL - the policy.issued event carries no term, and a premium charge does not need one[6] | Kafka projection |
| Scheme-level seat subscription (quote funnel) | NULL | NULL - N seats at once, no single policy behind it[12] | Stripe webhook |
| Claim reimbursement | set | set | Kafka 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
| Invariant | Enforced by |
|---|---|
locator unique (both tables) | DB unique constraints[1][2] |
| One account per employer | DB UNIQUE on org_locator[1] |
| Concurrent account creation converges on one row | Application: deterministic UUIDv5 id + ON CONFLICT DO NOTHING upsert[8][10] |
category, status vocabularies | Nothing in the DB - Go constants and convention only[9] |
charges.account_id points at a real account | Nothing - no FK, and for most legacy rows it genuinely does not (§9) |
| An unknown plan never prices to £0 | Application: 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 charge | Application: the consumer does not commit the Kafka offset on handler error - retried on restart[15] |
| Row changes captured to CDC | Debezium 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 whosepolicy_schemais empty, so the catalogue path prices them - the pricing package comment documents the three-way order verbatim[5]. Both plan-name vocabularies (base|recommended|unlimitedfrom the funnel,BASE|STANDARD|UNLIMITEDfrom enrollment) resolve to the same three tiers[5]. - The event's account is ignored on purpose.
policy.issuedcarries no usable account (the policy'saccount_idis enrollment's synthetic per-member UUIDv5,"flow0-account:" + partyLocator, minted under aTODO(#1164)[20]). The handler resolves scheme → employer → real account instead[6]. The claim path (HandleClaimApproved) still trusts the event'saccount_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):
{
"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
}{
"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)"
}{
"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):
| Key | Read by | What actually happens |
|---|---|---|
org_locator: PTY-2026-000738 | every billing list endpoint | requests with this org_locator claim see exactly this account's charges and invoices; anyone else gets nothing |
scheme_locator: SCH-2026-001214 | draft-invoice creation[6] | denormalised onto the invoice so the employer dashboard finds it with one predicate |
| empty Stripe fields | payment-method tile | this 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]):
| Key | Read by | What actually happens |
|---|---|---|
account_id (uuidv5) | invoice consolidation | joins 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: null | lapse job[13] | nothing breaks: the event carried no term and none was fabricated |
member_locator: PTY-2026-000741 | employer dashboard, GET /internal/members/{party}/charges | the per-member line on the consolidated invoice; also the member portal's "what was billed for me" |
status: PENDING | invoice finalise | flips 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
| Where | Column / mechanism | Nature |
|---|---|---|
billing.invoices | account_id (+ denormalised org_locator, scheme_locator) | in-service uuid, no FK |
billing.invoice_line_items | charge_id | the charge-to-invoice join, no FK |
billing.payments, billing.ledger_entries, billing.installment_schedules | account_id | in-service uuid, no FK |
group_scheme.schemes | account_id | staged for the group-contract elevation (#219); NULL today |
EOB endpoints (/billing/eob/{claimLocator}) | claim_locator on CLAIM charges | claims-world join, locator not FK[21] |
| employer & member apps, web-admin, MCP tools | GET /billing/charges… scoped by org_locator claim | API, not schema |
8. Design determinations
- One billing account per employer, keyed by the party locator - the
org_locatorclaim 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. - Deterministic account identity - UUIDv5 + upsert lets two independent write paths (webhook, projection) converge without coordination. (§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) - Charge granularity = one member, one month - what makes the consolidated employer invoice per-member auditable via
scheme_locator/member_locator. (§3) - Claim payouts reuse the charge rail - a reimbursement is a CLAIM charge, not a parallel money model; EOB is an aggregation over it. (§6)
- Scheme-level charges get NULL policy/term rather than fabricated references - the funnel's seat subscription relaxed the original NOT NULLs. (§3)
- 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 add | Where |
|---|---|---|
| A new money kind - tax, admin fee, excess, adjustment | A 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 change | ChargeCategory 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 place | Model decision #1438; charges.category + member_locator[2] |
| Direct-to-consumer payers | An 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 unchanged | billing.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 it | projection/handlers.go[6]; topic list[19]; consumer loop[15] |
| Claim payouts to attribute to the real payer | Resolve the account from the claim's scheme or member rather than trusting the event's account_id, reusing EnsureForScheme as the premium path does | HandleClaimApproved[6] → account.EnsureForScheme[8] |
| A second market or currency | A 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 replace | Resolver.Market[5]; currency columns[1][2] |
| Per-product pricing beyond tier | A populated policy_schema on the product version; the preferred ruleset resolver then takes over from the catalogue path with no billing change | pricing.go resolver order[5] |
| Recurring monthly collection | The cycle job. Accrual, the PENDING → INVOICED → PAID transitions and the lapse finder already exist; the schedule is the remaining piece | Model decision #1438, cycle job #1439 |
Known defects - the fix, and where it goes:
account_idis 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 joiningcharges → accountsand 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 bymember_locatorbefore any FK is added.- The claim path writes the legacy lineage.
HandleClaimApproveduses the event's syntheticaccount_idas-is, which is where the dangling rows come from; the handlers' own comment scopes those flows out[6]. Fix: theEnsureForSchemeresolution the premium path already performs. - No DB enforcement of any vocabulary -
categoryand bothstatuscolumns are bare text, so a typo persists silently. Fix: CHECK constraints in a newservices/billing/migrationsstep, 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, toclaims.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.
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-L81billing/migrations/0002_create_charges.sql- charges DDL (policy_id/term_id originally NOT NULL)packages/go/domain/accounts.go#L10- the other accounts table (policy_admin.accounts)packages/go/domain/billing.go#L10- BillingAccount model + card-denormalisation commentbilling/internal/pricing/pricing.go#L1- one price owner, fallback table, dual-lineage product versions; aliases L46-L59; no-silent-zero L83-L110billing/internal/projection/handlers.go#L121- HandlePolicyIssued (three documented breaks + fixes); memberPremium L228-L264; HandleClaimApproved L375-L434; getOrCreateDraftInvoice L496-L527; legacy account path L529-L555packages/go/domain/charges.go#L10- Charge model; nullability comment L13-L19; traceability comment L31-L36billing/internal/service/account.go#L15-accountIDForUUIDv5; EnsureForScheme L54-L67; Ensure L72-L98packages/go/domain/enums.go#L33- ChargeCategory: PREMIUM, TAX, FEE, CLAIMbilling/internal/repository/gorm_accounts.go#L59-UpsertON CONFLICT (org_locator) DO NOTHINGbilling/internal/handler/payment_intents.go#L57- Stripe customer + PaymentIntent metadata (schemeLocator)billing/internal/service/onboarding.go#L164- seat-subscription charge, PolicyID/TermID nilbilling/internal/job/lapse.go#L29- overdue finder INNER JOIN on policy_idbilling/internal/service/locator.go#L15- in-process counter; boot seeding atcmd/server/main.go#L94billing/internal/projection/consumer.go#L67- no-commit-on-error + checkpoint save; event dispatch L81-L96enrollment/internal/service/policy.go#L618- policy.issued enqueued with topic stringenrollment.eventsenrollment/internal/outbox/worker.go#L48- publish now uses the row's topic viaPublishToTopic; producer bound atcmd/server/main.go#L107enrollment/internal/config/config.go#L54-KAFKA_TOPICdefaultenrollment-eventsbilling/internal/config/config.go#L92- billing subscribes the dual listenrollment-events,enrollment.eventsplusclaims.eventsenrollment/internal/service/policy.go#L503-TODO(#1164)synthetic"flow0-account:" + partyLocatorUUIDv5billing/internal/service/eob.go#L30- EOB aggregation over CLAIM charges by claim_locatordocument-service/internal/config/config.go#L54- subscribesclaims-events(hyphen) where billing readsclaims.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.
