Skip to content
Updated Aug 22, 2026

Invoices, payments & ledger

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

Tablesbilling.invoices, billing.invoice_line_items, billing.payments, billing.ledger_entries[1][2][3][4] (second ring: adjustments, installment_schedules, autopay_preferences)
Owner servicebilling (sole writer)
LocatorsINV- (invoices) · PAY- (payments) · LED- (ledger entries)
Last updated2026-08-28
CompanionERD 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 inJoin
the chargebilling.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 PDFdocument-service renders the INVOICE document when invoice.paid arrives on billing.events[19]; billing owns the fact, docsvc owns the artefactevent
the collection cyclethe 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 objectthe 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 authoritya 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

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE INV-
account_iduuidThe payer; indexed, no FK (dual lineage - see previous page §9)
statustextDefault 'DRAFT'; live values DRAFT | FINALISED | PAID; convention
total_amountnumeric(14,4)Default 0; recomputed as SUM of line items at finalise
currencychar(3)Default 'GBP'
due_datedateStamped +30 days at finalise; NULL on funnel invoices (deliberate - §5)
finalised_at, paid_attimestamptzLifecycle timestamps
grace_period_daysintNOT NULL default 30 - per-invoice dunning tolerance[6]
delinquent_at, lapsed_attimestamptzDunning timestamps, stamped by the delinquency job (no live rows stamped yet - §9)
org_locator, scheme_locatorvarchar(255) / textDenormalised from the account so GET /billing/invoices/list?orgLocator=… is one indexed predicate[7]
party_locatortextThe 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

FieldTypeReqNotes
iduuidPK. No locator - lines are not externally addressable
invoice_iduuidReal FKinvoices(id)[2]
charge_iduuidThe accrual behind the line; no FK
amount, descriptionnumeric(14,4) / text✓/-Copied from the charge at add time

payments

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE PAY-
invoice_iduuidReal FKinvoices(id)[3]
account_iduuidCopied from the invoice; no FK
amount / currencynumeric(14,4) / char(3)Money shape
methodtextDIRECT_DEBIT | CARD | BANK_TRANSFER (convention); live: 78 CARD, 1 BANK_TRANSFER
referencetextFree text; the funnel path stores the PaymentIntent id here too
statustextDefault 'SETTLED'; SETTLED | VOID
stripe_payment_intent_idtextPartial UNIQUE index (excluding NULL and '') - the webhook idempotency guard[7]
settled_attimestamptzWhen the money moved

ledger_entries

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE LED-
account_iduuidIndexed; no FK
policy_iduuidIndexed - but written as the zero uuid by most paths (§9)
entry_typetextDeclared CHARGE | PAYMENT | ADJUSTMENT_DEBIT | ADJUSTMENT_CREDIT | REVERSAL[5]; live: PAYMENT, ADJUSTMENT_CREDIT, REVERSAL only
reference_id, reference_typeuuid / textPolymorphic pointer (PAYMENT | ADJUSTMENT | CHARGE), no FK
directiontextDEBIT | CREDIT
amount / currencynumeric(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

InvariantEnforced by
All three locators uniqueDB unique constraints[1][3][4]
A line item belongs to a real invoice; a payment settles a real invoiceDB 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 PaymentIntentDB partial UNIQUE index (excl. NULL and '')[7]
Only DRAFT finalises; only FINALISED takes a payment; only SETTLED voidsApplication guards in the service layer[8][11]
PAID means settled payments ≥ totalApplication, inside the same transaction as the payment insert (reads its own write)[11]
Business write + outbox event commit atomicallyApplication: 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 balanceNot on its own yet - the CHARGE debit is reserved and unposted, so a balance composes charges + invoices + ledger (§9)
Row changes captured to CDCDebezium 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}/finalise recomputes the total, stamps due_date +30 days, flips every line's charge to INVOICED, and emits invoice.finalised - all in one transaction[8].
  • Payment. POST /billing/payments records a payment against a FINALISED invoice, appends the PAYMENT/CREDIT ledger entry, and - if settled payments now cover the total - marks the invoice PAID, flips the charges to PAID and emits invoice.paid[11]. Voiding a payment reverses all of it: REVERSAL/DEBIT entry, 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_date stays 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_days past due → DELINQUENT (+invoice.delinquent), twice the grace → LAPSED (+invoice.lapsed)[16]. A separate hourly lapse job crosses the service boundary: overdue invoice → enrollment.LapsePolicy for 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 in main.go (§9).

6. Populated example

Two real invoices from the live DB - one from each write path (values exact; demo estate):

json
{
  "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" }
  ]
}
json
{
  "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:

KeyRead byWhat actually happens
status: DRAFT, one per accountgetOrCreateDraftInvoice[9]the next enrolment on this scheme appends line 4 here, not a new invoice
org_locator: PTY-2026-000738employer dashboard listone indexed predicate finds this employer's invoices; the same value is the JWT claim, so the employer can only see their own
line_items[].chargeper-member drill-downeach line joins back to a charge carrying member_locator - "whose £19.50" is answerable per line
due_date: nullfinalise[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:

KeyRead byWhat actually happens
finalised_at = paid_atanyone auditingthe tell of the funnel path: born finalised-and-paid, never outstanding
payment.stripe_payment_intent_ididempotency gate + partial UNIQUE index[10]Stripe redelivered this event: no second payment can exist for pi_e2e_…149
ledger_entry PAYMENT/CREDITGET /billing/ledger?account_id=…[18]the settlement appears on the account's ledger; the matching CHARGE debit is the §9 extension
invoice.paid outbox eventdocument-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

WhereColumn / mechanismNature
billing.invoice_line_items, billing.paymentsinvoice_idreal FKs (in-service)
document-serviceinvoice.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
enrollmentthe lapse job calls LapsePolicy for policies behind overdue invoices[17]HTTP, billing → enrollment
employer app / web-admin / MCP toolsGET /billing/invoices…, /payments…, /ledger…, scoped by org_locator claim[20]API
billing.ledger_entries(reference_id, reference_type) → payments / adjustmentspolymorphic soft ref, no FK

8. Design determinations

  1. 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.
  2. 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)
  3. 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)
  4. Idempotency is a DB constraint, not just a check - the partial UNIQUE on stripe_payment_intent_id wins even when redeliveries race the application gate. (§3)
  5. Dunning is data - per-invoice grace_period_days plus stamped timestamps; thresholds are grace and 2x grace. (§5)
  6. Billing owns the fact, docsvc owns the artefact - invoice.paid is the contract between them; billing never renders a PDF. (§7)
  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.
  8. 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.method takes 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 addWhere
an account balance derivable from the ledger alonepost the CHARGE/DEBIT entry alongside the charges → INVOICED update at finalise; the entry type and the direction column already carry itFinaliseInvoice[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 tablepackages/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 textpackages/go/domain/billing.go[5] · POST /billing/payments[20]
a second payment provider beside Stripea provider-reference column with its own partial UNIQUE (the ''-excluding predicate is the template) and a webhook handler mirroring the Stripe onemigration 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 chargeinvoice_line_items[2] · Billing account & charges
recurring monthly collectiona cycle job that opens the next DRAFT and finalises the closing one; both halves it calls already existjob under services/billing/internal/job/, calling getOrCreateDraftInvoice[9] and finalise[8]; model #1438, job #1439
dunning stamping live invoicesone NewDelinquencyJob start beside the lapse job at boot; the job itself is built and testedservices/billing/cmd/server/main.go[22][16]
a key account on softer dunning termsset grace_period_days on that invoice row - data, no deploybilling.invoices.grace_period_days[6]
instalment billing and autopay collectionthe generator that turns an ACTIVE schedule into its N invoices, and a run that consults autopay_preferences; both tables are in place0014_billing_lifecycle.sql[6] · Billing lifecycle (#14)
a currency other than GBPwrite the ISO code on the row; the money shape is per-row, not per-deploymentcurrency char(3) on invoices[1], payments[3] and ledger entries[4]

Known defects, and the fix:

  • GetBalance sums by a policy_id that is mostly the zero uuid. It computes DEBIT minus CREDIT by policy_id[21], but PolicyID is a non-pointer uuid, so every payment entry lands with 00000000-… (79 of 110 live rows), and a per-policy balance sees only adjustments. Fix: make PolicyID a *uuid.UUID in packages/go/domain/billing.go and stamp the real policy on the payment path in RecordPayment[11], or sum by account_id. Pair it with the CHARGE debit row above, or the sign stays incomplete.
  • The delinquency job has no caller outside tests. main.go boots the consumer, the outbox worker and the lapse job[22][16], so 1 117 FINALISED invoices past due_date carry no delinquent_at / lapsed_at stamp. Fix: start it in cmd/server/main.go beside the lapse job.
  • Status vocabulary drift - the model comment says DRAFT | FINALISED | PAID | VOID; the repository also writes DELINQUENT and LAPSED[15]. Fix: extend the comment/constants in packages/go/domain/billing.go[5] and add a CHECK in a new billing migration.
  • invoice_line_items.charge_id has no FK even though charges live in the same schema - the one in-service join here that is convention rather than constraint. Fix: one ALTER TABLE … ADD FOREIGN KEY migration, on the precedent of the invoice_id FK[2].
  • The billing.events envelope trap - billing's outbox worker publishes with the topic in the envelope's event_type slot; the real event name lives at payload.eventType, so a consumer reading only the envelope drops every invoice.paid (document-service documents and works around exactly this)[23]. Fix: publish the event name in event_type in 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 as path:line (not pinned blob links) because the code is not yet on main; check-code-refs.py will 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 RegisterPaymentIntentRoutesRegisterSubscriptionRoutes, 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-closed503 if StripeSecretKey is unset (subscriptions.go:49-52)

What it creates in Stripe, in order:

Stripe objectHowWhy
Customercustomer.New with metadata{quoteLocator, channel:"D2C", party_locator} (subscriptions.go:63-69)the member's Stripe identity; metadata is the thread the webhook pulls
Productproduct.New (subscriptions.go:71-78)required to back an inline subscription price, so no pre-created Stripe Price is needed
Subscriptioninline 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.

StepWhereDetail
Webhook receives eventhandler/stripe_webhook.go:40-110fails 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 paidstripe_webhook.go:131-137MarkQuotePaidthe 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 pathstripe_webhook.go:175-193MarkQuoteFailedrecords 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 itenrollment service/quote.go:869-891Issue() 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 hopbilling 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 surfacedenrollment handler/handler.go:199-203ErrPaymentRequired402 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 eventpayment_intent.succeeded (schemeLocator)payment_intent.succeeded (quoteLocator)
Writescharge + invoice + line + payments + ledger_entries, one tx (born PAID)quote_payments row and, since olly#1739, account + charge + invoice + payment + ledger via SettleDirectPurchase
Recurrenceone seat subscription, single intentreal Stripe Subscription, Recurring{Interval}
Issue gatenone (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:line against branch feat/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:

SideOwnsAuthoritative for
Stripe subscriptioncollection attempts, retries, card lifecyclesettlement - whether money actually moved
billing.installment_schedules + installment_itemswhat is expected, when, against which policyexpectation - what should have been collected
the reconciliation sweepcomparing the twodetecting drift, which a human adjudicates

Three consequences for this page's tables:

  1. 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, the invoice.payment_succeeded branch). Idempotency is layered: per Stripe event.id (billing.stripe_events, migration 0023), per Stripe invoice id (the instalment advance), and per PaymentIntent id (the ledger write).
  2. Billing decides when the term ends. On the Nth paid instalment the schedule flips COMPLETED and billing cancels the Stripe subscription, rather than relying on a Stripe cancel_at that dunning could skew (internal/service/installment.go, RecordCyclePayment).
  3. Drift is surfaced, never auto-healed. A sweep diffs each active schedule against Stripe and records persistent mismatches in billing.reconciliation_drifts (migration 0024) as UNEXPECTED_CHARGE, AMOUNT_MISMATCH or UNCOLLECTED_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.

  1. billing/migrations/0003_create_invoices.sql - invoices DDL
  2. billing/migrations/0004_create_invoice_line_items.sql - line items DDL, invoice FK L4
  3. billing/migrations/0005_create_payments.sql - payments DDL, invoice FK L5
  4. billing/migrations/0007_create_ledger_entries.sql - ledger DDL, polymorphic reference columns
  5. packages/go/domain/billing.go#L52 - Invoice L52-L76 (incl. PartyLocator L70-L73) · InvoiceLineItem L87-L94 · Payment (+ idempotency comment L115-L119) L105-L122 · QuotePayment L133-L150 · Adjustment L161-L173 · LedgerEntry (declared vocabularies) L184-L197
  6. billing/migrations/0014_billing_lifecycle.sql - dunning columns L4-L7 · installment_schedules L10-L24 · autopay_preferences L27-L33
  7. billing/migrations/0018_create_accounts.sql#L46 - invoice org/scheme denormalisation L46-L53 · webhook-idempotency partial UNIQUE (with the '' predicate rationale) L69-L81
  8. billing/internal/service/billing.go#L80 - FinaliseInvoice: DRAFT guard L89-L93, total = SUM L95-L106, charges → INVOICED + outbox in one tx L108-L127
  9. billing/internal/projection/handlers.go#L496 - getOrCreateDraftInvoice ("consolidated employer invoice")
  10. 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-L264
  11. billing/internal/service/billing.go#L135 - RecordPayment: FINALISED guard L144-L148, PAYMENT/CREDIT ledger L178-L188, in-tx paid check + charges → PAID + invoice.paid L206-L228
  12. billing/internal/service/billing.go#L326 - ApplyAdjustment: type → direction/entry_type mapping + ledger append
  13. billing/internal/service/billing.go#L245 - VoidPayment: REVERSAL/DEBIT L265-L275, PAID invoice back to FINALISED L293-L297
  14. billing/internal/service/billing.go#L39 - the transactional-outbox invariant, stated on the type
  15. billing/internal/repository/gorm_invoices.go#L223 - MarkDelinquent / MarkLapsed status writes
  16. billing/internal/job/delinquency.go#L100 - Tick: grace → DELINQUENT L127-L136, 2x grace → LAPSED L113-L122; overdue finder L39-L56
  17. billing/internal/job/lapse.go#L29 - overdue-invoice → policy join L29-L45; non-transactional saga caveat L47-L58
  18. billing/internal/handler/ledger.go#L12 - ledger list, tenancy-scoped to the caller's account
  19. document-service/internal/kafka/consumer.go#L25 - invoice.paid → INVOICE document ("the employer's receipt")
  20. billing/internal/handler/handler.go#L337 - invoice / payment / adjustment / ledger routes (finalise at L340)
  21. billing/internal/repository/gorm_ledger.go#L59 - GetBalance: DEBIT minus CREDIT by policy_id
  22. billing/cmd/server/main.go#L200 - boot wiring: consumer + lapse job only; no delinquency job
  23. document-service/internal/kafka/consumer.go#L121 - the topic-in-event_type envelope trap on billing.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.

Olly Health Insurance Platform