Invoices, payments & ledger
Schema deep-dive · living document · #9 in the reading sequence
| Tables | billing.invoices, billing.invoice_line_items, billing.payments, billing.ledger_entries[1][2][3][4] (second ring: adjustments, installment_schedules, autopay_preferences) |
| Owner service | billing (sole writer) |
| Locators | INV- (invoices) · PAY- (payments) · LED- (ledger entries) |
| Last updated | 2026-08-28 |
| Companion | ERD story, slide 9 · real policy JSON · plan documents · previous: Billing account & charges |
1. Scope and usage
This is the settlement side of billing. The invoice is the consolidated per-employer statement: charges accrued on the account roll into one DRAFT invoice as line items, the draft is finalised into a payable document, and a payment settles it. Underneath, ledger entries record money movements as directed (DEBIT/CREDIT) facts.
Two things distinguish these tables in the estate. First, the invoice lifecycle is data: finalised_at, paid_at, grace_period_days, delinquent_at, lapsed_at are columns, so dunning state is queryable history, not just a status word. Second, this is where the platform's only real foreign keys in the billing database live - both pointing at invoices (§4).
2. Boundaries and relationships
| An invoice / payment / ledger entry is not… | That concern lives in | Join |
|---|---|---|
| the charge | billing.charges is the accrual fact; the invoice is the statement that collects them - a line item carries both invoice_id and charge_id[2] | charge_id (no FK) |
| the invoice PDF | document-service renders the INVOICE document when invoice.paid arrives on billing.events[19]; billing owns the fact, docsvc owns the artefact | event |
| the collection cycle | the spine accrues, finalises and settles on demand; minting next month's DRAFT on a schedule is a job on top of these tables, not a schema change - model decision #1438, cycle job #1439 | - |
| a Stripe object | the payment row is billing's own record; Stripe's PaymentIntent is referenced by stripe_payment_intent_id, and the card lives on the account, not here[5] | id string |
| the balance authority | a balance composes charges + invoices + ledger. The ledger carries settlement movements today; the CHARGE debit that would let it stand alone is one posting away (§9) | - |
3. Structure
DDL[1][3][4] · dunning columns[6] · Go models[5]
invoices
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE INV- |
account_id | uuid | ✓ | The payer; indexed, no FK (dual lineage - see previous page §9) |
status | text | ✓ | Default 'DRAFT'; live values DRAFT | FINALISED | PAID; convention |
total_amount | numeric(14,4) | ✓ | Default 0; recomputed as SUM of line items at finalise |
currency | char(3) | ✓ | Default 'GBP' |
due_date | date | Stamped +30 days at finalise; NULL on funnel invoices (deliberate - §5) | |
finalised_at, paid_at | timestamptz | Lifecycle timestamps | |
grace_period_days | int | ✓ | NOT NULL default 30 - per-invoice dunning tolerance[6] |
delinquent_at, lapsed_at | timestamptz | Dunning timestamps, stamped by the delinquency job (no live rows stamped yet - §9) | |
org_locator, scheme_locator | varchar(255) / text | Denormalised from the account so GET /billing/invoices/list?orgLocator=… is one indexed predicate[7] | |
party_locator | text | The individual customer's PTY- locator, the D2C mirror of org_locator: a direct purchase has no employer and no scheme, so without it an invoice could not be found from the customer (migration 0021, olly#1739). Nullable, with a partial index WHERE party_locator IS NOT NULL, so employer invoices carry no index entry[5] |
invoice_line_items
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK. No locator - lines are not externally addressable |
invoice_id | uuid | ✓ | Real FK → invoices(id)[2] |
charge_id | uuid | ✓ | The accrual behind the line; no FK |
amount, description | numeric(14,4) / text | ✓/- | Copied from the charge at add time |
payments
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE PAY- |
invoice_id | uuid | ✓ | Real FK → invoices(id)[3] |
account_id | uuid | ✓ | Copied from the invoice; no FK |
amount / currency | numeric(14,4) / char(3) | ✓ | Money shape |
method | text | ✓ | DIRECT_DEBIT | CARD | BANK_TRANSFER (convention); live: 78 CARD, 1 BANK_TRANSFER |
reference | text | Free text; the funnel path stores the PaymentIntent id here too | |
status | text | ✓ | Default 'SETTLED'; SETTLED | VOID |
stripe_payment_intent_id | text | Partial UNIQUE index (excluding NULL and '') - the webhook idempotency guard[7] | |
settled_at | timestamptz | ✓ | When the money moved |
ledger_entries
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE LED- |
account_id | uuid | ✓ | Indexed; no FK |
policy_id | uuid | Indexed - but written as the zero uuid by most paths (§9) | |
entry_type | text | ✓ | Declared CHARGE | PAYMENT | ADJUSTMENT_DEBIT | ADJUSTMENT_CREDIT | REVERSAL[5]; live: PAYMENT, ADJUSTMENT_CREDIT, REVERSAL only |
reference_id, reference_type | uuid / text | ✓ | Polymorphic pointer (PAYMENT | ADJUSTMENT | CHARGE), no FK |
direction | text | ✓ | DEBIT | CREDIT |
amount / currency | numeric(14,4) / char(3) | ✓ | Money shape |
Field-by-field: what and why
invoices.status + the timestamp columns - the status word is convention, but every transition also stamps a timestamp (finalised_at, paid_at, delinquent_at, lapsed_at), so the lifecycle is reconstructible even if the word were wrong. Why per-invoice grace_period_days rather than a global config? Dunning tolerance is commercial, not technical - a key account can carry 60 where the default is 30, with no deploy.
total_amount - starts 0 on the draft and is recomputed as the SUM of line items at finalise[8]. The draft's running total is therefore trustworthy only after finalise; the lines are the truth before that.
invoice_line_items - the consolidation join: one employer invoice, one line per member premium (or claim reimbursement), each line naming its charge_id. This is what "consolidated per-employer invoice" physically is - members enrolled during a cycle accumulate onto the employer's one open draft[9]. Amount and description are copied from the charge, so the invoice renders without a join and stays stable if the charge is later voided.
payments.stripe_payment_intent_id - the idempotency key. Stripe redelivers payment_intent.succeeded until it gets a 2xx; the partial UNIQUE index means a redelivery can never mint a second payment - the INSERT loses and the handler treats that as already-processed[10][7]. The index predicate excludes '' as well as NULL because the Go field is a plain string: every non-Stripe payment writes '', and without the exclusion the second bank transfer would collide with the first.
ledger_entries - double-entry-shaped, settlement-populated. Direction plus entry type gives each row accounting meaning: a PAYMENT/CREDIT when money arrives[11], an ADJUSTMENT_CREDIT/ADJUSTMENT_DEBIT when an adjustment applies[12], a REVERSAL in the opposite direction when a payment or adjustment is undone[13]. The (reference_id, reference_type) pair points at the causing row polymorphically - one ledger, many source tables, no FK. That shape is what makes the ledger extensible: a write-off, a refund, a commission clawback or a credit note is a new entry_type value plus the direction it posts in, absorbed without a migration on ledger_entries. The CHARGE debit half of the double entry is declared in the vocabulary and reserved for the accrual side; until it is posted, a balance composes charges, invoices and the ledger together (§9).
Second ring - adjustments (17 live rows; PENDING → APPLIED → REVERSED, each transition writing its ledger entry), installment_schedules (split a total into N invoices; 4 live rows, create/cancel endpoints), autopay_preferences (per-policy flag, reserved for autopay collection; 0 rows)[6]. These three, plus the D2C quote_payments table, extend this same spine and have their own page: Billing lifecycle (#14).
4. Invariants
| Invariant | Enforced by |
|---|---|
| All three locators unique | DB unique constraints[1][3][4] |
| A line item belongs to a real invoice; a payment settles a real invoice | DB FKs - the only two foreign keys in the billing database (live pg_constraint): invoice_line_items.invoice_id and payments.invoice_id[2][3]. In-service FKs are allowed; the no-FK rule applies only across services |
| One payment per Stripe PaymentIntent | DB partial UNIQUE index (excl. NULL and '')[7] |
| Only DRAFT finalises; only FINALISED takes a payment; only SETTLED voids | Application guards in the service layer[8][11] |
| PAID means settled payments ≥ total | Application, inside the same transaction as the payment insert (reads its own write)[11] |
| Business write + outbox event commit atomically | Application: every multi-step operation runs in one db.Transaction (the transactional-outbox invariant)[14] |
| Status vocabularies (invoice, payment, ledger) | Nothing in the DB - and the Go comment's invoice vocabulary (DRAFT | FINALISED | PAID | VOID) omits the DELINQUENT / LAPSED values the repository writes[5][15] |
| Ledger derives the balance | Not on its own yet - the CHARGE debit is reserved and unposted, so a balance composes charges + invoices + ledger (§9) |
| Row changes captured to CDC | Debezium publication dbz_billing (live \d) |
5. Lifecycle
Four paths through that diagram, each with its own owner:
- Accrual → finalise. The projection accumulates lines on the open draft (previous page);
PATCH /billing/invoices/{locator}/finaliserecomputes the total, stampsdue_date+30 days, flips every line's charge toINVOICED, and emitsinvoice.finalised- all in one transaction[8]. - Payment.
POST /billing/paymentsrecords a payment against a FINALISED invoice, appends thePAYMENT/CREDITledger entry, and - if settled payments now cover the total - marks the invoice PAID, flips the charges to PAID and emitsinvoice.paid[11]. Voiding a payment reverses all of it:REVERSAL/DEBITentry, invoice back to FINALISED[13]. - The funnel shortcut. When the Stripe webhook settles the quote funnel's first payment, the invoice is born FINALISED and PAID in the same insert - charge, invoice, line, payment and ledger entry in one transaction, so an employer can never have an invoice without its payment[10]. Its
due_datestays NULL on purpose: nothing was ever outstanding[10]. The card save happens outside the transaction: a slow Stripe must not roll back money already taken[10]. - Dunning. The delinquency job walks overdue FINALISED invoices:
grace_period_dayspast due → DELINQUENT (+invoice.delinquent), twice the grace → LAPSED (+invoice.lapsed)[16]. A separate hourly lapse job crosses the service boundary: overdue invoice →enrollment.LapsePolicyfor the policies behind its unpaid charges, with an honest saga caveat in the comment (the HTTP call and the local outbox write cannot share a transaction)[17]. The lapse job is started at boot; starting the delinquency job beside it is one call inmain.go(§9).
6. Populated example
Two real invoices from the live DB - one from each write path (values exact; demo estate):
{
"locator": "INV-2026-000236",
"status": "DRAFT",
"total_amount": "58.5000", "currency": "GBP",
"due_date": null,
"finalised_at": null, "paid_at": null,
"grace_period_days": 30,
"delinquent_at": null, "lapsed_at": null,
"org_locator": "PTY-2026-000738",
"scheme_locator": "SCH-2026-001214",
"line_items": [
{ "amount": "19.5000", "charge": "CHG-2026-000626", "member": "PTY-2026-000739" },
{ "amount": "19.5000", "charge": "CHG-2026-000627", "member": "PTY-2026-000740" },
{ "amount": "19.5000", "charge": "CHG-2026-000628", "member": "PTY-2026-000741" }
]
}{
"invoice": {
"locator": "INV-2026-000244",
"status": "PAID",
"total_amount": "50.0000", "currency": "GBP",
"due_date": null,
"finalised_at": "2026-08-12T17:30:57Z",
"paid_at": "2026-08-12T17:30:57Z",
"org_locator": "PTY-2026-000021",
"scheme_locator": "SCH-2026-001005",
"line_items": [
{ "amount": "50.0000", "charge": "CHG-2026-000636",
"description": "Monthly subscription - 5 members (Standard)" }
]
},
"payment": {
"locator": "PAY-2026-000083",
"amount": "50.0000", "method": "CARD", "status": "SETTLED",
"reference": "pi_e2e_1786555857214134149",
"stripe_payment_intent_id": "pi_e2e_1786555857214134149",
"settled_at": "2026-08-12T17:30:57Z"
},
"ledger_entry": {
"locator": "LED-2026-000122",
"entry_type": "PAYMENT", "direction": "CREDIT",
"reference_type": "PAYMENT",
"amount": "50.0000",
"description": "Card payment for invoice INV-2026-000244"
}
}What each value does downstream:
The consolidated draft - three members enrolled onto scheme SCH-2026-001214 in the same cycle, three £19.50 STANDARD-tier premium lines, one employer invoice:
| Key | Read by | What actually happens |
|---|---|---|
status: DRAFT, one per account | getOrCreateDraftInvoice[9] | the next enrolment on this scheme appends line 4 here, not a new invoice |
org_locator: PTY-2026-000738 | employer dashboard list | one indexed predicate finds this employer's invoices; the same value is the JWT claim, so the employer can only see their own |
line_items[].charge | per-member drill-down | each line joins back to a charge carrying member_locator - "whose £19.50" is answerable per line |
due_date: null | finalise[8] | stamped +30 days only when the draft finalises; until then nothing is owed |
The funnel-settled chain - the employer paid £50 for 5 seats in the quote funnel; the webhook produced all four artefacts in one transaction:
| Key | Read by | What actually happens |
|---|---|---|
finalised_at = paid_at | anyone auditing | the tell of the funnel path: born finalised-and-paid, never outstanding |
payment.stripe_payment_intent_id | idempotency gate + partial UNIQUE index[10] | Stripe redelivered this event: no second payment can exist for pi_e2e_…149 |
ledger_entry PAYMENT/CREDIT | GET /billing/ledger?account_id=…[18] | the settlement appears on the account's ledger; the matching CHARGE debit is the §9 extension |
invoice.paid outbox event | document-service[19] | the INVOICE PDF (the employer's receipt) is generated from this event |
Continuity note: the worked-example employer from the earlier pages (PTY-2026-000001, Olldemo) has no billing.accounts row live - it predates the funnel/accounts flow - so the money chain is shown on employers that have transacted through it.
7. Who references an invoice / payment / ledger entry
| Where | Column / mechanism | Nature |
|---|---|---|
billing.invoice_line_items, billing.payments | invoice_id | real FKs (in-service) |
| document-service | invoice.paid / invoice.finalised on billing.events → INVOICE document; an enricher calls back into billing's /internal/invoices/{locator} for the full rows[19] | event + internal API |
| enrollment | the lapse job calls LapsePolicy for policies behind overdue invoices[17] | HTTP, billing → enrollment |
| employer app / web-admin / MCP tools | GET /billing/invoices…, /payments…, /ledger…, scoped by org_locator claim[20] | API |
billing.ledger_entries | (reference_id, reference_type) → payments / adjustments | polymorphic soft ref, no FK |
8. Design determinations
- One consolidated invoice per employer per cycle - members accrue as line items on the account's open draft; per-member attribution rides the charge, not a per-member invoice. Flow-0 fix, #1164.
- In-service FKs are used where they can be - line items and payments really reference invoices; the cross-service no-FK rule was never meant to apply inside one schema. (§4)
- The funnel invoice is born paid, atomically - charge + invoice + line + payment + ledger in one transaction; an invoice can never exist without its payment on that path. (§5)
- Idempotency is a DB constraint, not just a check - the partial UNIQUE on
stripe_payment_intent_idwins even when redeliveries race the application gate. (§3) - Dunning is data - per-invoice
grace_period_daysplus stamped timestamps; thresholds are grace and 2x grace. (§5) - Billing owns the fact, docsvc owns the artefact -
invoice.paidis the contract between them; billing never renders a PDF. (§7) - Recurring collection is a job, not a schema change - the accrual, finalise and settle spine already carries a cycle's worth of billing; minting next month's DRAFT on a schedule sits on top of it: model #1438, job #1439.
- The money model was built ahead of the products that use it - the double-entry ledger takes new movement kinds as data (
entry_type+direction+ a polymorphic reference),payments.methodtakes new settlement rails as data, and a line item carries whatever charge category accrued. Adding premium instalments, refunds, write-offs or a second payment provider is configuration and a handler, not a reshape of these four tables. (§9)
9. Caveats and extensibility
Group and individual. The chain is payer-agnostic end to end: an invoice hangs off an account, and an individual's account (own party locator, NULL scheme_locator) would flow through finalise → pay → ledger identically. Nothing on these four tables knows what a scheme is beyond the denormalised locator column - the group/individual fork was resolved one page earlier.
What this spine already absorbs. The four tables are deliberately generic about what kind of money moves through them. ledger_entries is a double-entry shape (entry_type + direction + a polymorphic (reference_id, reference_type)) that takes a new movement kind as a value, not a migration; payments.method and payments.reference take a new settlement rail the same way; invoice_line_items carries whatever charge category accrued, because amount and description are copied from the charge. Live movements today are PAYMENT/CREDIT (79), ADJUSTMENT_CREDIT/CREDIT (16) and REVERSAL/DEBIT (15); the declared CHARGE type is reserved for the accrual side of the entry.
Extension points:
| When we need … | What to add | Where |
|---|---|---|
| an account balance derivable from the ledger alone | post the CHARGE/DEBIT entry alongside the charges → INVOICED update at finalise; the entry type and the direction column already carry it | FinaliseInvoice[8] · vocabulary in packages/go/domain/billing.go[5]; no migration on ledger_entries[4] |
| a new money movement (refund, write-off, credit note, commission clawback) | one entry_type value plus the direction it posts in; the polymorphic reference already points at any source table | packages/go/domain/billing.go[5] plus the service path that raises it, on the ApplyAdjustment pattern[12] |
| a new payment rail (Direct Debit, BACS, open banking) | the method value and its settlement call; DIRECT_DEBIT is already in the declared vocabulary and the column is bare text | packages/go/domain/billing.go[5] · POST /billing/payments[20] |
| a second payment provider beside Stripe | a provider-reference column with its own partial UNIQUE (the ''-excluding predicate is the template) and a webhook handler mirroring the Stripe one | migration beside 0018_create_accounts.sql[7] · services/billing/internal/handler/stripe_webhook.go → settle path[10] |
| a new charge category on the statement (excess, co-pay, claim reimbursement, admin fee) | nothing on these tables: the line copies amount and description from whichever charge accrued, so the category rides the charge | invoice_line_items[2] · Billing account & charges |
| recurring monthly collection | a cycle job that opens the next DRAFT and finalises the closing one; both halves it calls already exist | job under services/billing/internal/job/, calling getOrCreateDraftInvoice[9] and finalise[8]; model #1438, job #1439 |
| dunning stamping live invoices | one NewDelinquencyJob start beside the lapse job at boot; the job itself is built and tested | services/billing/cmd/server/main.go[22][16] |
| a key account on softer dunning terms | set grace_period_days on that invoice row - data, no deploy | billing.invoices.grace_period_days[6] |
| instalment billing and autopay collection | the generator that turns an ACTIVE schedule into its N invoices, and a run that consults autopay_preferences; both tables are in place | 0014_billing_lifecycle.sql[6] · Billing lifecycle (#14) |
| a currency other than GBP | write the ISO code on the row; the money shape is per-row, not per-deployment | currency char(3) on invoices[1], payments[3] and ledger entries[4] |
Known defects, and the fix:
GetBalancesums by apolicy_idthat is mostly the zero uuid. It computes DEBIT minus CREDIT by policy_id[21], butPolicyIDis a non-pointer uuid, so every payment entry lands with00000000-…(79 of 110 live rows), and a per-policy balance sees only adjustments. Fix: makePolicyIDa*uuid.UUIDinpackages/go/domain/billing.goand stamp the real policy on the payment path inRecordPayment[11], or sum byaccount_id. Pair it with theCHARGEdebit row above, or the sign stays incomplete.- The delinquency job has no caller outside tests.
main.goboots the consumer, the outbox worker and the lapse job[22][16], so 1 117 FINALISED invoices pastdue_datecarry nodelinquent_at/lapsed_atstamp. Fix: start it incmd/server/main.gobeside the lapse job. - Status vocabulary drift - the model comment says
DRAFT | FINALISED | PAID | VOID; the repository also writesDELINQUENTandLAPSED[15]. Fix: extend the comment/constants inpackages/go/domain/billing.go[5] and add a CHECK in a new billing migration. invoice_line_items.charge_idhas no FK even though charges live in the same schema - the one in-service join here that is convention rather than constraint. Fix: oneALTER TABLE … ADD FOREIGN KEYmigration, on the precedent of theinvoice_idFK[2].- The
billing.eventsenvelope trap - billing's outbox worker publishes with the topic in the envelope'sevent_typeslot; the real event name lives atpayload.eventType, so a consumer reading only the envelope drops everyinvoice.paid(document-service documents and works around exactly this)[23]. Fix: publish the event name inevent_typein billing's outbox worker, and migrate consumers off the workaround. - The lapse job's cross-service step is not transactional - policy lapsed via HTTP, then a local outbox write; the worst case ("billing event missed, policy already lapsed") is accepted in the comment[17]. Fix: a compensating saga, or move the lapse to an event enrollment consumes.
10. D2C recurring premium (Stripe Subscription)
D2C QnB additions, grounded in branch
feat/1675, pending merge. The paths below are cited aspath:line(not pinned blob links) because the code is not yet onmain;check-code-refs.pywill pin them once it merges.
The employer funnel settles one seat subscription as a single PaymentIntent (§5, the born-PAID invoice). The direct-to-consumer "Quote & Buy" (QnB) flow sells a member their own cover on a recurring premium, so billing creates a real Stripe Subscription rather than a one-off intent.
Superseded by ENG-454 - billing now holds the schedule
As first built, the Stripe Subscription was the D2C billing schedule: no internal schedule table stood behind it, and Stripe alone carried the cadence. ENG-454 (branch feat/eng-454-recurring-premium, PR #1750, pending merge) inverts that: billing holds the instalment schedule as the system of record and Stripe collects against it, with a reconciliation sweep comparing the two. The subsections below still describe the create-and-settle path accurately; §10.1 records what ENG-454 adds on top. See the Recurring Premium & Reconciliation design (docs/site/designs/recurring-premium.md, lands with PR #1750) and Billing lifecycle §3.
The contract
POST /onboarding/subscriptions/d2c (mounted under /onboarding via RegisterPaymentIntentRoutes → RegisterSubscriptionRoutes, services/billing/internal/handler/payment_intents.go:19, subscriptions.go:17-19).
| Body | {quoteLocator, partyLocator, amountPence, currency?, interval?} - quoteLocator + amountPence>0 required; currency defaults gbp, interval is month|year defaulting month (subscriptions.go:34-60) |
| Returns | {subscriptionId, customerId, clientSecret, paymentIntentId, interval, amountPence, currency, quoteLocator, status} (subscriptions.go:121-131) |
| Fail-closed | 503 if StripeSecretKey is unset (subscriptions.go:49-52) |
What it creates in Stripe, in order:
| Stripe object | How | Why |
|---|---|---|
| Customer | customer.New with metadata{quoteLocator, channel:"D2C", party_locator} (subscriptions.go:63-69) | the member's Stripe identity; metadata is the thread the webhook pulls |
| Product | product.New (subscriptions.go:71-78) | required to back an inline subscription price, so no pre-created Stripe Price is needed |
| Subscription | inline price_data (currency, product, UnitAmount=amountPence, Recurring{Interval}), PaymentBehavior:"default_incomplete", expand latest_invoice.payment_intent (subscriptions.go:80-99) | default_incomplete means the first invoice is created with a PaymentIntent the client must confirm; the subscription activates on payment |
The handler reads clientSecret + paymentIntentId off the expanded first invoice (subscriptions.go:105-109), then stamps the same {quoteLocator, channel:"D2C", party_locator} metadata onto that first PaymentIntent with paymentintent.Update (subscriptions.go:112-119). That stamp is the whole linkage: it lets the existing payment_intent.succeeded webhook settle the quote with no extra wiring. A one-shot D2C variant (POST /onboarding/payment-intents/d2c, payment_intents.go:18) exists for a single-payment checkout; it settles through the same webhook by the same metadata.
The payment → issue handshake
The D2C purchase settles a quote, before any policy or invoice exists, so it does not write billing.payments (invoice-scoped). It writes billing.quote_payments (0019_create_quote_payments.sql:6-16, model packages/go/domain/billing.go:135-148), keyed UNIQUE on quote_locator, and that row is the gate enrollment reads.
| Step | Where | Detail |
|---|---|---|
| Webhook receives event | handler/stripe_webhook.go:40-110 | fails closed 503 with no STRIPE_WEBHOOK_SECRET (:44-48), verifies the signature over the raw body (:72-83), routes payment_intent.succeeded (:86-94) |
| Quote marked paid | stripe_webhook.go:131-137 → MarkQuotePaid | the quoteLocator metadata branch (vs the funnel's schemeLocator); upserts quote_payments SETTLED idempotently on quote_locator and emits quote.payment_settled on billing.events, in one tx (repository/gorm_quote_payments.go:35-62) |
| Failure path | stripe_webhook.go:175-193 → MarkQuoteFailed | records FAILED + quote.payment_failed, but never downgrades an already-SETTLED row (gorm_quote_payments.go:67-94), so a late failure after a successful retry cannot un-pay the quote |
| Issue gate reads it | enrollment service/quote.go:869-891 | Issue() calls quoteRequiresPrepayment (channel:"D2C" or requires_prepayment:true, quote.go:347-360); a prepaid quote with no verifier, or an unpaid one, returns ErrPaymentRequired (fail-closed). Employer/scheme quotes carry neither flag and skip the gate |
| Verifier hop | billing GET /quotes/{quoteLocator}/payment (handler/quote_payments.go:26-28,75-99) | cluster-internal read (paid boolean + amount, no PII); the settling PaymentIntent id it returns is stamped onto policy.PaymentIntentID at issue (quote.go:888-890,897-908) |
| 402 surfaced | enrollment handler/handler.go:199-203 | ErrPaymentRequired → 402 Payment Required, so the checkout UI distinguishes "pay first" from a real error |
A test/mock settle path exists for when a signed webhook is not available: POST /internal/quotes/{quoteLocator}/mark-paid, X-Internal-Service-gated, calls the same MarkQuotePaid (handler/quote_payments.go:33-73). Real payments always settle via the signed webhook.
What this path does and does not write
| Employer funnel (§5) | D2C subscription (§10) | |
|---|---|---|
| Settlement event | payment_intent.succeeded (schemeLocator) | payment_intent.succeeded (quoteLocator) |
| Writes | charge + invoice + line + payments + ledger_entries, one tx (born PAID) | quote_payments row and, since olly#1739, account + charge + invoice + payment + ledger via SettleDirectPurchase |
| Recurrence | one seat subscription, single intent | real Stripe Subscription, Recurring{Interval} |
| Issue gate | none (employer invoice-billed) | quote_payments.SETTLED gates issuance (402 until paid) |
The two gaps this section originally recorded have since been closed, one on main and one pending merge.
Gap 1, closed on main (olly#1739). The D2C path used to record the first-premium settlement on quote_payments alone, so the premium never reached the invoices/payments/ledger_entries spine and no D2C invoice existed to show the customer. SettleDirectPurchase now does the employer path's work for an individual payer: get-or-create the account, then charge, invoice, payment and a ledger credit in one transaction with outbox events, idempotent on the PaymentIntent id (internal/service/direct_purchase.go). Both the signed webhook and the internal mark-paid route call the same helper (handler/stripe_webhook.go:221-251), so they cannot diverge, and it skips rather than guesses when the PaymentIntent carries no party_locator. Migration 0021_invoice_party_locator.sql adds the nullable party_locator column plus a partial index (WHERE party_locator IS NOT NULL), the D2C mirror of org_locator, so an invoice is findable from the customer.
Gap 2, closed pending merge (ENG-454, PR #1750). The webhook handled payment_intent.succeeded / .payment_failed only, so Stripe's renewal invoices had no consumer and recurring collection past the first premium was Stripe-side with no billing row per cycle. ENG-454 adds invoice.payment_succeeded / invoice.payment_failed / customer.subscription.deleted, each matched to a schedule by stripe_subscription_id; a paid cycle advances its instalment and writes that cycle's invoice + payment + ledger through the same SettleDirectPurchase spine (idempotent on the cycle PaymentIntent, so the first cycle does not double-post). See §10.1.
10.1 Billing holds the schedule (ENG-454, pending merge)
Cited as plain
path:lineagainst branchfeat/eng-454-recurring-premium(PR #1750); these pin once it merges. Table structure lives on Billing lifecycle §3; this subsection records only what it means for the invoice/payment/ledger spine.
The design is both sides, reconciled, not Stripe instead of billing:
| Side | Owns | Authoritative for |
|---|---|---|
| Stripe subscription | collection attempts, retries, card lifecycle | settlement - whether money actually moved |
billing.installment_schedules + installment_items | what is expected, when, against which policy | expectation - what should have been collected |
| the reconciliation sweep | comparing the two | detecting drift, which a human adjudicates |
Three consequences for this page's tables:
- A cycle is a first-class ledger event. Each paid renewal invoice writes its own invoice + payment + ledger entry through
SettleDirectPurchase, so the account ledger shows N premiums over the term rather than only the first (internal/handler/stripe_webhook.go, theinvoice.payment_succeededbranch). Idempotency is layered: per Stripeevent.id(billing.stripe_events, migration 0023), per Stripe invoice id (the instalment advance), and per PaymentIntent id (the ledger write). - Billing decides when the term ends. On the Nth paid instalment the schedule flips
COMPLETEDand billing cancels the Stripe subscription, rather than relying on a Stripecancel_atthat dunning could skew (internal/service/installment.go,RecordCyclePayment). - Drift is surfaced, never auto-healed. A sweep diffs each active schedule against Stripe and records persistent mismatches in
billing.reconciliation_drifts(migration 0024) asUNEXPECTED_CHARGE,AMOUNT_MISMATCHorUNCOLLECTED_INSTALMENT, past a settling window so an in-flight cycle is not drift and a webhook-lag mismatch auto-resolves. Ops adjudicate them in the ollyverse queue; the amount-weighted persistent-drift ratio is held under 0.01% (internal/service/reconcile.go). Over-collection (a Stripe invoice with no expected instalment left) is deliberately recorded as drift rather than invented as a new instalment or dropped.
References
Code links are pinned to commit 08e65216 on main (2026-08-28); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py. §10 (D2C QnB) cites branch feat/1675 as plain path:line, and §10.1 branch feat/eng-454-recurring-premium; those pin on merge.
billing/migrations/0003_create_invoices.sql- invoices DDLbilling/migrations/0004_create_invoice_line_items.sql- line items DDL, invoice FK L4billing/migrations/0005_create_payments.sql- payments DDL, invoice FK L5billing/migrations/0007_create_ledger_entries.sql- ledger DDL, polymorphic reference columnspackages/go/domain/billing.go#L52- Invoice L52-L76 (incl.PartyLocatorL70-L73) · InvoiceLineItem L87-L94 · Payment (+ idempotency comment L115-L119) L105-L122 · QuotePayment L133-L150 · Adjustment L161-L173 · LedgerEntry (declared vocabularies) L184-L197billing/migrations/0014_billing_lifecycle.sql- dunning columns L4-L7 · installment_schedules L10-L24 · autopay_preferences L27-L33billing/migrations/0018_create_accounts.sql#L46- invoice org/scheme denormalisation L46-L53 · webhook-idempotency partial UNIQUE (with the''predicate rationale) L69-L81billing/internal/service/billing.go#L80- FinaliseInvoice: DRAFT guard L89-L93, total = SUM L95-L106, charges → INVOICED + outbox in one tx L108-L127billing/internal/projection/handlers.go#L496- getOrCreateDraftInvoice ("consolidated employer invoice")billing/internal/service/onboarding.go#L100- SettleFunnelPayment: idempotency gate L124-L132, born-PAID invoice + nil due_date L181-L193, single tx L206-L259, card save outside L261-L264billing/internal/service/billing.go#L135- RecordPayment: FINALISED guard L144-L148, PAYMENT/CREDIT ledger L178-L188, in-tx paid check + charges → PAID + invoice.paid L206-L228billing/internal/service/billing.go#L326- ApplyAdjustment: type → direction/entry_type mapping + ledger appendbilling/internal/service/billing.go#L245- VoidPayment: REVERSAL/DEBIT L265-L275, PAID invoice back to FINALISED L293-L297billing/internal/service/billing.go#L39- the transactional-outbox invariant, stated on the typebilling/internal/repository/gorm_invoices.go#L223- MarkDelinquent / MarkLapsed status writesbilling/internal/job/delinquency.go#L100- Tick: grace → DELINQUENT L127-L136, 2x grace → LAPSED L113-L122; overdue finder L39-L56billing/internal/job/lapse.go#L29- overdue-invoice → policy join L29-L45; non-transactional saga caveat L47-L58billing/internal/handler/ledger.go#L12- ledger list, tenancy-scoped to the caller's accountdocument-service/internal/kafka/consumer.go#L25-invoice.paid→ INVOICE document ("the employer's receipt")billing/internal/handler/handler.go#L337- invoice / payment / adjustment / ledger routes (finalise at L340)billing/internal/repository/gorm_ledger.go#L59- GetBalance: DEBIT minus CREDIT by policy_idbilling/cmd/server/main.go#L200- boot wiring: consumer + lapse job only; no delinquency jobdocument-service/internal/kafka/consumer.go#L121- the topic-in-event_type envelope trap onbilling.events
Live-schema facts (FK list, status/method/entry-type distributions, dunning timestamp counts, zero-uuid policy ids, second-ring row counts, Debezium publication) come from psql -h 10.0.1.2 -U olly -d billing · \d billing.invoices, \d billing.payments, \d billing.ledger_entries, select entry_type, direction, reference_type, count(*) from billing.ledger_entries group by 1,2,3, select conname from pg_constraint where contype='f' and connamespace='billing'::regnamespace, select count(*) from billing.ledger_entries where policy_id = '00000000-0000-0000-0000-000000000000', 2026-08-18.
