Skip to content
Updated Aug 22, 2026

Billing lifecycle: adjustments, installments & autopay

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

Tablesbilling.adjustments[1], billing.installment_schedules, billing.autopay_preferences[2], billing.quote_payments[3] · ENG-454 (pending merge): billing.installment_items, billing.stripe_events, billing.reconciliation_drifts (§3.1)
Owner servicebilling (sole writer)
LocatorsADJ-<uuid> (adjustments) · ISC-YYYY-NNNNNN (installment schedules); autopay & quote_payments are natural-key (policy_id / quote_locator)
Last updated2026-08-28
CompanionERD story · Billing (narrative) · previous: Invoices, payments & ledger

1. Scope and usage

The previous two pages covered the wired billing spine: an account accrues charges, which consolidate onto an invoice that a payment settles and a ledger records. This page is the second ring those pages named in passing: the four tables that sit around that spine to make money move in ways the monthly cycle does not - a manual ledger correction, a pay-monthly plan, a stored autopay intent, and the pre-bind deposit a direct-to-consumer member pays before any policy exists.

Two of the four carry their capability ahead of the products that use it.autopay_preferences already models the standing instruction - one row per policy, enabled plus method - so turning autopay on is a writer and a collection run against a shape that exists, not a migration (§9). And the card that run would charge is already on the account: billing stores stripe_payment_method_id with brand/last4/expiry per account[37]. installment_schedules records the whole plan a pay-monthly sale needs (total, frequency, derived installment count, start date, status) behind a create/cancel API; the generator that raises the N invoices from a schedule is the one piece to add, and §9 names where it goes. Both landed in one migration (0014) alongside the dunning columns the invoices page covers, ahead of the recurring-collection work model decision #1438 and cycle job #1439 sequence. Each switches on when its stage arrives.

Two are live and wired. adjustments is the one manual money lever that actually posts to the ledger: a credit or debit against an account, applied in the same transaction as its ADJUSTMENT_* ledger entry and its outbox event (§5). quote_payments is newer and orthogonal to everything else here: it is the D2C member's pre-bind deposit (#1675), taken via Stripe on a quote before a policy, account or invoice exists - which is exactly why it keys on quote_locator + party_locator and not account_id. It is also the one table on this page that is not in the dbz_billing Debezium publication (§4).

2. Boundaries and relationships

This is not…That concern lives inJoin
a chargebilling.charges is an accrual (a premium owed, a claim to reimburse); an adjustment is a manual correction that posts straight to the ledger with no invoice behind it[9]ledger_entries.reference_id = adjustments.id, reference_type='ADJUSTMENT'
a paymentbilling.payments settles an invoice (invoice-scoped, employer/scheme); a quote_payment settles a single quote before any invoice exists[3]none - different key (quote_locator, not invoice_id)
the invoices it would splita schedule is the plan (N, frequency, start); the generator that turns it into invoices is the extension point named in §9[15]installment_schedules.account_id (soft)
the collection engineautopay_preferences.enabled is the stored intent; the autopay run that reads it is the extension point named in §9-
the policyenrollment.policies; enrollment reads the quote-payment gate before issuing, then owns the policy[33]quote_locator (soft, cross-service)
the Stripe objectStripe owns the PaymentIntent; quote_payments stores its id and the settled/failed fact, nothing more. Since olly#1739 the same webhook branch also calls settleDirect, so the money additionally reaches the invoice/payment/ledger spine - but that is written there, not here[25]stripe_payment_intent_id

Every join out of these tables is a soft reference: account_id and policy_id are bare uuids with no FK (the cross-service posture the whole billing DB keeps, previous page §4), and quote_locator / party_locator are text locators into enrollment and Party that billing never verifies.

3. Structure

DDL[1][2][3][4] · Go models[5][6][7][8]

adjustments

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE. ADJ-<uuid> - minted in the handler as fmt.Sprintf("ADJ-%s", uuid.New()), not the in-process sequence (§3 below, defect §9)[13]
account_iduuidThe account credited/debited; no FK. Tenancy is enforced on it (§4)
policy_iduuidNullable in DB; the create handler mandates it, and the Go field is a non-pointer uuid, so a live row always carries one[13]
typetextCREDIT | DEBIT (convention); drives ledger direction/entry-type (§5). Bare text with no DB vocabulary, so a finer set of correction kinds is a Go constant, not a migration (§9)
amount / currencynumeric(14,4) / char(3)Platform money shape; currency default 'GBP'
reasontextFree text ("goodwill"); copied to the ledger entry's description
statustextDefault 'PENDING'; PENDING → APPLIED → REVERSED (convention)
applied_attimestamptzStamped when APPLIED; NULL until then
created_attimestamptzBookkeeping

installment_schedules

FieldTypeReqNotes
id / locatoruuid / textPK / UNIQUE. ISC-YYYY-NNNNNN from the in-process counter[18]
account_iduuidThe account to bill; indexed (idx_installment_account), no FK
policy_iduuidNullable; Go field is *uuid.UUID here (a genuine optional, unlike adjustments)
total_amount / currencynumeric(14,4) / char(3)The sum to split
frequencytextmonthly | quarterly | annually - the only accepted values[15]
installmentsintDerived from frequency, not supplied: monthly→12, quarterly→4, annually→1[15]
start_datedateWhen the plan begins - the first due date the generator reads (§9)
statustextDefault 'ACTIVE'; the only other value written was CANCELLED[17]. ENG-454 adds COMPLETED, set when the Nth instalment is paid (§3.1)
created_at, updated_attimestamptzBookkeeping

ENG-454 additions (pending merge). Migration 0022 links a schedule to the Stripe subscription that collects it and to the payer, so the sweep in §3.1 can join Stripe back to billing: stripe_subscription_id (indexed, partial WHERE NOT NULL), stripe_customer_id, party_locator, quote_locator. All four are nullable - a legacy employer schedule has no Stripe subscription behind it.

autopay_preferences

FieldTypeReqNotes
iduuidPK
policy_iduuidUNIQUE - one row per policy; the intended key (no locator)
enabledbooleanNOT NULL default false - the per-policy on/off the collection run reads (§9)
methodtextWhich instrument to collect with; nullable, and bare text, so a new method (card, direct debit, open banking) is a value not a migration
updated_attimestamptzBookkeeping

quote_payments

FieldTypeReqNotes
iduuidPK
quote_locatortextUNIQUE - the idempotency key; the enrollment gate reads by it[34]
stripe_payment_intent_idtextThe settling PaymentIntent; indexed (idx_quote_payments_pi)
amount_pencebigintPence, not numeric(14,4) - straight from Stripe's integer minor units (§3 below)
currencychar(3)Default 'GBP'; live rows carry Stripe's lowercase 'gbp' (defect, §9)
statustextSETTLED | FAILED (no PENDING; a row is only written on a terminal Stripe outcome)
settled_attimestamptzSet on SETTLED; NULL on FAILED
failure_reasontextSet on FAILED, from Stripe's last_payment_error (added by 0020)[4]
party_locatortextThe D2C member the payment/failure targets (added by 0020)
created_attimestamptzBookkeeping

3.1 ENG-454: the schedule of record and its reconciliation (pending merge)

Branch feat/eng-454-recurring-premium, PR #1750. Cited as plain path:line because the code is not on main; these pin on merge. Design: Recurring Premium & Reconciliation (docs/site/designs/recurring-premium.md, lands with the same PR).

Until ENG-454 a schedule was a header with a count: total_amount, frequency, installments, start_date and nothing per instalment, which is why §9 listed the invoice generator as an extension point. ENG-454 makes the schedule the system of record for a D2C recurring premium: Stripe collects, billing states what was expected, and a sweep compares them.

installment_items (migration 0022)

One row per expected collection - the grain a reconciler can join a Stripe invoice to. UNIQUE (schedule_id, seq).

FieldTypeReqNotes
iduuidPK
schedule_iduuidFK installment_schedules(id) ON DELETE CASCADE - the first real FK in this family (the schedule's own account_id is still soft)
seqint1..N; the lowest not-yet-PAID row is the one a cycle settles
due_datedateStepped from start_date by frequency (monthly/quarterly/annually)
amount / currencynumeric(14,4) / char(3)Per-cycle amount in major units, unlike quote_payments.amount_pence (§9)
statustextSCHEDULED -> ATTEMPTING -> PAID | FAILED | PAST_DUE
stripe_invoice_idtextThe Stripe invoice that settled this instalment; indexed. The reconciliation join key
stripe_payment_intent_idtextThe cycle's PaymentIntent, which is also the ledger write's idempotency key
paid_attimestamptzSet on PAID
created_at, updated_attimestamptzBookkeeping

PAST_DUE is a reconciliation observation, not a written state: an item past due beyond the settling window with no collection stays SCHEDULED/FAILED, and the drift row is what makes it visible.

stripe_events (migration 0023)

event_id (PK, Stripe's evt_…), event_type, received_at. Billing's existing idempotency is per business object (quote_locator, PaymentIntent id); recurring invoice events need a guard at the event grain so a redelivery is a true no-op. An event is marked processed only after successful handling, so a 500 still retries.

reconciliation_drifts (migration 0024)

One row per persistent discrepancy awaiting human adjudication. Natural-key upsert on (schedule_id, drift_type, COALESCE(seq,-1), COALESCE(stripe_invoice_id,'')), so a repeat sweep refreshes rather than duplicates.

FieldTypeReqNotes
iduuidPK
schedule_id / schedule_locator / party_locatoruuid / text / textWhose drift it is
drift_typetextUNEXPECTED_CHARGE | UNCOLLECTED_INSTALMENT | AMOUNT_MISMATCH | OVER_COLLECTION
seq, stripe_invoice_idint, textWhich instalment / which Stripe invoice
expected_amount, observed_amountnumeric(14,4)The two sides, side by side
amount_deltanumeric(14,4)Absolute money at risk; this column is the SLO numerator
statustextOPEN | RESOLVED
first_detected_at / last_detected_attimestamptzfirst_detected_at drives the settling window: a mismatch younger than it is in-flight, not drift
resolved_at / resolved_by / resolution_notetimestamptz / text / textThe audit trail of the human call

Drift is surfaced, never auto-healed. A row closes when a human adjudicates it in the ollyverse queue, or when a sweep no longer detects it (webhook lag clearing itself). The amount-weighted persistent-drift ratio - SUM(amount_delta) over OPEN rows older than the settling window, divided by all scheduled premium

  • is held under 0.01%. Every drift write and its reconciliation.drift.detected / .resolved / .adjudicated event share one commit on the transactional outbox, so the record and the announcement cannot diverge.

Field-by-field: what and why

adjustments is the ledger's manual lever. Apply reads the row, maps type to a ledger direction (CREDIT → ADJUSTMENT_CREDIT/CREDIT, DEBIT → ADJUSTMENT_DEBIT/DEBIT), and writes the adjustment status flip, the ledger entry and the adjustment.applied outbox message in one transaction[9]. Reverse is the mirror: it posts a REVERSAL entry in the opposite direction and flips to REVERSED[10]. Crucially, the ledger entry copies adj.PolicyID verbatim, so adjustment entries carry a real policy_id - which is why the previous page's GetBalance (a per-policy_id sum) "sees only adjustments": the payment entries it should also see were written with the zero uuid[9] (invoices page §9).

The adjustment locator breaks billing's own convention. Every other billing locator (CHG-, INV-, PAY-, LED-, ISC-) comes from the in-process LocatorGenerator as PREFIX-YEAR-%06d[18]; the adjustment handler instead mints ADJ-<raw uuid> inline[13]. So billing.accounts/charges diverge from the shared DB-sequence machinery (billing-charges §3), and adjustments diverges again, from billing's own counter.

installment_schedules.installments is a function of frequency, not an input. CreateSchedule looks frequency up in a fixed map (monthly→12, quarterly→4, annually→1) and rejects anything else; the caller never states a count[15]. The row is the plan: create emits installment.created, cancel emits installment.cancelled, and everything a generator needs to raise the invoices (total_amount, installments, start_date, frequency, status) is already on it. Reading a schedule back to raise those invoices is the one addition, and §9 names the package it goes in.

autopay_preferences is reserved for the collection engine. The DDL, the model and a single table-name registry test are the only places the table is named[7][35]; the repository, service method, handler and collection run are what autopay adds on top of it (§9). The constraint that matters is already enforced by the DB: UNIQUE policy_id, so a policy can never hold two conflicting standing instructions, whichever surface writes the row.

quote_payments speaks Stripe's dialect on purpose. It stores amount_pence as a bigint (Stripe's integer minor units), not the platform's numeric(14,4), and it keys on quote_locator because at write time there is no account or invoice to hang off - a D2C member is paying a quote's premium before the quote becomes a policy[3]. The write path is the Stripe webhook: payment_intent.succeeded with a quoteLocator in its metadata calls MarkQuotePaid, which upserts on quote_locator so a redelivered event never duplicates[25][23]; payment_intent.payment_failed calls MarkQuoteFailed, whose ON CONFLICT carries a WHERE status <> 'SETTLED' guard so a late failure can never un-pay a quote that already succeeded on retry[24].

4. Invariants

InvariantEnforced by
adjustments.locator, installment_schedules.locator uniqueDB UNIQUE[1][2]
One autopay row per policyDB UNIQUE on autopay_preferences.policy_id[2]
One quote_payment per quote (webhook idempotency)DB UNIQUE on quote_payments.quote_locator + ON CONFLICT DO UPDATE upsert[3][23]
A SETTLED quote never regresses to FAILEDApplication: ON CONFLICT … WHERE status <> 'SETTLED' on the failure path[24]
Adjustment apply is legal only from PENDING; reverse only from APPLIEDApplication: the repository asserts the from-status before updating[12]
Applying/reversing an adjustment posts its ledger entry atomicallyApplication: status flip + ledger append + outbox in one runInTx[9][10]
frequency is one of monthly/quarterly/annuallyApplication: unknown frequency → 422, no row written[15]
A tenant only touches its own account's adjustments / schedulesApplication: create is 403 on a foreign account_id; read/apply/reverse/cancel 404 a foreign locator[13][14]
Only a signed Stripe event may mint a quote_paymentApplication: webhook fails closed without the signing secret; settle-by-hand is X-Internal-Service-gated[27][30]
type, status, method, frequency vocabulariesNothing in the DB - all bare text, held by Go constants and convention. This is what lets a new adjustment kind or autopay method land without a migration (§9)
Row changes captured to CDCDebezium publication dbz_billing for adjustments, installment_schedules, autopay_preferences (live pg_publication_tables); quote_payments is deliberately excluded (§9)

5. Lifecycle

The four tables have four different shapes of lifecycle: a real state machine (adjustments), a two-state toggle (installments), a webhook-driven terminal outcome (quote_payments), and a stored intent rather than a machine (autopay).

Adjustments - the richest, and fully wired:

Create writes a PENDING row and emits adjustment.created[11]; apply and reverse are the two ledger-posting transitions above[9][10]. There is no PENDING → REVERSED path: you can only reverse what you applied.

Installment schedules - ACTIVE → CANCELLED, and nothing else. Create sets ACTIVE + installment.created; cancel sets CANCELLED + installment.cancelled[16]. The third transition - a schedule producing its next invoice - is what the generator adds, and it needs no new state: an ACTIVE schedule plus its start_date and frequency is a complete instruction (§9).

Autopay is a stored intent, not a state machine: enabled + method per policy, set by whoever owns the standing instruction and read by the collection run at charge time. There is one state to hold, which is why the table has no status column.

Quote payments - born terminal, driven entirely by Stripe:

The gate is the point of the table. A prepaid (D2C) quote may not be issued until its premium settles, or a paid product could be obtained for £0; enrollment's IssueQuote fails closed - a requires-prepayment quote with no verifier or no settled payment is refused with ErrPaymentRequired[33], reading the status over GET /internal/quotes/{q}/payment[34]. A /internal/quotes/{q}/mark-paid write exists for the mock/test path (no Stripe signature), behind the X-Internal-Service guard[21].

6. Populated example

An applied adjustment, walked to its ledger entry

ADJ-7d106ed8-… is the one live adjustment in a non-reversed applied state - a £25 goodwill credit (real row, reason is not PII):

json
{
  "id": "3ea55ffc-4096-4266-a065-ed5c2aa44f8f",
  "locator": "ADJ-7d106ed8-e08f-497c-b8d7-bae7db49ddff",
  "account_id": "9397f5e1-a715-413a-b6a7-e03e2ed0cb25",
  "policy_id": "b87710ed-2293-4b44-9e51-cb0ce2b8649c",
  "type": "CREDIT", "amount": "25.0000", "currency": "GBP",
  "reason": "goodwill",
  "status": "APPLIED",
  "applied_at": "2026-06-14T21:26:54Z",
  "created_at": "2026-06-14T21:26:28Z"
}
KeyRead byWhat actually happens
type: CREDITApplyAdjustment[9]maps to entry_type=ADJUSTMENT_CREDIT, direction=CREDIT on the ledger entry
policy_id: b87710ed-…copied onto the ledger entrythe adjustment's ledger row carries a real policy id (unlike payment entries) - this is the only thing GetBalance per-policy sees
status: APPLIED/adjustments/{loc}/reverse guardreverse is now legal; a second apply is rejected (not PENDING)
account_idtenancythe caller must own this account to see or move it

The apply produced exactly one ledger row, in the same transaction (live):

LED-2026-000002 | ADJUSTMENT_CREDIT | CREDIT | reference_type=ADJUSTMENT
                | reference_id=3ea55ffc-… | amount=25.0000

Live population: 17 adjustments - all type=CREDIT (no DEBIT has ever been raised), split 1 APPLIED · 1 PENDING · 15 REVERSED. The 15 reversed rows are e2e fixtures (reason = "e2e adjustment.reversed …"), which is why the ledger census shows 16 ADJUSTMENT_CREDIT and 15 REVERSAL/DEBIT entries: 16 applied at some point, 15 later reversed.

An installment schedule (ahead of need)

ISC-2026-000001, one of 4 live rows (all ACTIVE; same demo account as the adjustment above):

json
{
  "locator": "ISC-2026-000001",
  "account_id": "9397f5e1-a715-413a-b6a7-e03e2ed0cb25",
  "policy_id": "b87710ed-2293-4b44-9e51-cb0ce2b8649c",
  "total_amount": "1200.0000", "currency": "GBP",
  "frequency": "monthly", "installments": 12,
  "start_date": "2026-07-01", "status": "ACTIVE"
}

installments: 12 and start_date describe a complete instruction - raise twelve £100 invoices from 2026-07-01 - and the generator §9 names is what reads it. The 4 live rows are one per frequency (12/4/1 installments) plus a second monthly, the shape of a test rather than a customer.

A settled quote payment (the live D2C deposit)

QTE-2026-001149, the most recent of 18 rows, all SETTLED (no FAILED live). party_locator is a Party locator, not PII:

json
{
  "id": "dbbe03d0-9034-4566-a237-0262d4bbd599",
  "quote_locator": "QTE-2026-001149",
  "stripe_payment_intent_id": "pi_3U6w7wD0Lfaf6xfS1dXfwJXY",
  "amount_pence": 3703,
  "currency": "gbp",
  "status": "SETTLED",
  "settled_at": "2026-08-21T17:21:35Z",
  "party_locator": "PTY-2026-001208",
  "failure_reason": ""
}
KeyRead byWhat actually happens
quote_locator: QTE-2026-001149enrollment issuance gate[34]GET /internal/quotes/QTE-2026-001149/payment{paid: true}, so IssueQuote may proceed
amount_pence: 3703nothing downstream£37.03, stored in Stripe's integer minor units, not numeric
currency: "gbp"display onlylowercase - passed straight from Stripe's pi.Currency; the char(3) default 'GBP' is only used on the manual mark-paid path (live: 17 gbp, 1 GBP)
party_locator: PTY-2026-001208outbox quote.payment_settledthe notification pipeline targets the member; not an account
settled_at ≈ created_at-the row is born SETTLED - there is no PENDING quote_payment

autopay_preferences: reserved ahead of need

0 live rows as of 2026-08-21, so the row below is the shape the DDL and the model fully determine rather than one pulled from the database:

json
{ "id": "<uuid>", "policy_id": "<uuid, unique>", "enabled": false, "method": null }

The model and a table-name test are the Go that names it today[7][35]; §9 lists the four pieces autopay adds on top of it, and none of them is a schema change.

7. Who references these tables

WhereColumn / mechanismMeaning there
billing.ledger_entries(reference_id, reference_type='ADJUSTMENT')every applied/reversed adjustment posts a ledger entry pointing back at it (polymorphic, no FK)[9]
enrollmentGET /internal/quotes/{q}/payment (issuance gate)reads the D2C quote's settled status before turning it into a policy[33][34]
Novu / notificationsquote.payment_settled / quote.payment_failed on billing.eventsthe D2C welcome / payment-failed notification pipeline[23]
document-service / consumersadjustment.*, installment.* on billing.eventsoutbox announcements; no dedicated consumer acts on them today
employer app / web-admin / OllyverseGET/POST /billing/adjustments…, POST /billing/installments… (scoped by org_locator)[28][29]ops raises credits / schedules; tenancy-scoped
autopay_preferencesreserved for the autopay collection runits reader is the run §9 describes; the account's saved card is the instrument that run charges[38]

None are FKs; all are locator / event / API references (the cross-service rule). Internally the ledger entry is the one place an adjustment is joined to, and even that is polymorphic rather than a constraint.

8. Design determinations

  1. An adjustment is a ledger movement, not an invoice line. Applying one posts an ADJUSTMENT_CREDIT/ADJUSTMENT_DEBIT entry directly; reversing posts a REVERSAL in the opposite direction. The status machine and the ledger entry move in one transaction[9].
  2. The D2C deposit is quote-scoped, not account-scoped. A member pays before a policy/account/invoice exists, so quote_payments keys on quote_locator + party_locator and lives outside billing.payments entirely. #1675[3].
  3. Issuance is gated on settlement, fail-closed. Enrollment refuses to issue a prepaid quote whose payment has not settled, so a paid product cannot be obtained for £0[33].
  4. Webhook idempotency is a DB constraint plus a settle-wins guard. UNIQUE quote_locator + upsert makes redelivery a no-op; WHERE status <> 'SETTLED' on the failure path means a late decline never un-pays a succeeded retry[24].
  5. quote_payments is out of the CDC publication on purpose - it is a transient pre-bind artefact enrollment reads synchronously, not a fact the analytics/CDC stream needs; the other three tables are in dbz_billing (§4, §9).
  6. Installment count derives from frequency - a fixed monthly/quarterly/annually → 12/4/1 map, not a free integer, so a schedule cannot claim an arbitrary number of installments[15].
  7. Autopay and installments were modelled ahead of the products that need them, so adding those products is configuration and code rather than a migration. Both landed in migration 0014 alongside the dunning columns, so the standing instruction (unique per policy, enabled + method) and the pay-monthly plan (total, frequency, count, start date) are already shapes the DB enforces; the recurring cycle that drives them sequences behind model #1438 and cycle job #1439. The same posture runs through the vocabularies: type, method and frequency are bare text, so a new correction kind or collection instrument is a Go constant.

9. Caveats and extensibility

Group and individual. These tables serve both structures without a schema change, because the group/individual fork was already resolved upstream on Billing account & charges: an adjustment and an installment schedule hang off an account_id, and an account is payer-agnostic (an individual is an account whose org_locator holds their own party locator). quote_payments is the most individual of all - it is the direct-to-consumer member's own deposit, keyed on their party_locator, with no scheme or employer anywhere in the row. autopay is per-policy_id, which is member-granular by construction. Nothing here needs to know whether the payer is an employer or a person.

Extension points - when, what, where:

When we need …What to addWhere
to collect by autopay (a member or employer on a standing instruction instead of paying an invoice)a preferences writer - repository, service method and a GET/PUT route - plus a collection run that selects enabled = true policies and charges each account's saved cardservices/billing/internal/repository/ (a gorm_autopay.go beside gorm_installments.go), internal/service/, internal/handler/, and the run in internal/job/, registered in cmd/server/main.go the way the lapse and delinquency tickers are[39]. No schema change: the table is unique per policy and already carries enabled + method[2], and the instrument to charge is on the account (stripe_payment_method_id, brand/last4/expiry)[37]
to sell pay-monthlydelivered by ENG-454, pending mergeRather than a generator raising N invoices from a header, the schedule now carries one installment_items row per expected collection and a Stripe subscription collects against it; each paid cycle writes its own invoice, payment and ledger entry, and billing cancels the subscription on the Nth (§3.1)services/billing/internal/service/installment.go (ProvisionSubscriptionSchedule, RecordCyclePayment), the invoice.payment_* branches in internal/handler/stripe_webhook.go, and the sweep in internal/service/reconcile.go + internal/job/reconciliation.go
D2C payments in CDC / analytics on the same footing as the rest of billingALTER PUBLICATION dbz_billing ADD TABLE billing.quote_payments, then let the Debezium connector pick the new table upthe live dbz_billing publication on the billing database. The table's exclusion is a scope choice (determination 5), not a schema limit - nothing in the DDL prevents capture
finer correction categories (GOODWILL, WRITE_OFF, PREMIUM_REFUND … beyond CREDIT/DEBIT)a Go constant and whatever surface offers it; the ledger direction map in ApplyAdjustment gains a caseadjustments.type is bare text with no CHECK and no enum[1], so new kinds need no migration - only services/billing/internal/service/billing.go's direction map[9]
a DEBIT adjustment (a clawback, not a credit)nothing - the code path is built and tested; all 17 live rows happen to be CREDITApplyAdjustment already maps DEBIT → ADJUSTMENT_DEBIT/DEBIT[9]
more reaction to a D2C payment (dunning a decline, a retry nudge, a refund)a consumer on the existing outbox eventsquote.payment_settled / quote.payment_failed on billing.events already carry party_locator, so a Novu subscriber lookup needs no new column[23][4]

Known defects - the fix, and where:

These are wiring mismatches rather than stages the product has not reached, and each one is a concrete change.

  • Mint ADJ- from billing's own generator. The adjustment handler builds ADJ-<raw uuid> with an inline fmt.Sprintf[13] while every other billing locator (CHG-, INV-, PAY-, LED-, ISC-) comes from the PREFIX-YEAR-%06d counter[18] - two minting schemes in one service, and adjustment locators that no ops tool can sort or guess. Fix: call LocatorGenerator.Generate("ADJ") in services/billing/internal/handler/adjustments.go, and backfill or accept the 17 existing uuid-form locators.
  • Normalise quote_payments.currency to upper case. The Stripe webhook passes pi.Currency through verbatim, so 17 of 18 live rows hold 'gbp' against a column default of 'GBP'[25]. Any case-sensitive join or filter against the rest of the estate's 'GBP' misses them. Fix: upper-case on write in services/billing/internal/handler/stripe_webhook.go, plus a one-off UPDATE billing.quote_payments SET currency = upper(currency).
  • Reconcile amount_pence with the platform money shape. It is a bigint in Stripe's minor units while every other money column in billing is numeric(14,4)[3], so any reader summing money across billing tables has to know to divide this one by 100. Fix: either add a generated numeric(14,4) amount column in a billing migration, or convert once at the read boundary in services/billing/internal/handler/quote_payments.go and state the unit in the response[22].
  • Align adjustments.policy_id nullability with its writer. The column is nullable in the DB, the Go field is a non-pointer uuid, and the create handler mandates a value[13], so a second writer that omitted it would store the zero uuid rather than NULL - the same shape that already broke per-policy balance on the payment entries (invoices page §9). Fix: make the column NOT NULL in a billing migration, or make the Go field *uuid.UUID - either way, one source of truth.

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.

  1. billing/migrations/0006_create_adjustments.sql - adjustments DDL; UNIQUE locator L4
  2. billing/migrations/0014_billing_lifecycle.sql - installment_schedules L10-L24 (account index L24) · autopay_preferences L27-L33 (UNIQUE policy_id L29)
  3. billing/migrations/0019_create_quote_payments.sql - quote_payments DDL + rationale (D2C, keyed on quote_locator, #1675)
  4. billing/migrations/0020_quote_payment_failure.sql - failure_reason + party_locator (payment-declined path, #1675)
  5. packages/go/domain/billing.go#L157 - Adjustment model (type CREDIT|DEBIT, status PENDING|APPLIED|REVERSED)
  6. packages/go/domain/billing.go#L204 - InstallmentSchedule model (PolicyID *uuid pointer)
  7. packages/go/domain/billing.go#L229 - AutopayPreference model (the only definition; the collection run of §9 is its reader)
  8. packages/go/domain/billing.go#L129 - QuotePayment model + the "before any policy/invoice exists" rationale
  9. billing/internal/service/billing.go#L322 - ApplyAdjustment: type→direction map, ledger append + outbox in one tx, real policy_id copied
  10. billing/internal/service/billing.go#L387 - ReverseAdjustment: REVERSAL entry, opposite direction
  11. billing/internal/service/billing.go#L572 - CreateAdjustment: PENDING row + adjustment.created outbox
  12. billing/internal/repository/gorm_adjustments.go#L60 - Apply (asserts PENDING) / Reverse (asserts APPLIED) status guards
  13. billing/internal/handler/adjustments.go#L26 - createAdjustmentHandler: ADJ-<uuid> locator L55, OwnsAccount 403 L46, policy_id mandated L50-L54
  14. billing/internal/handler/adjustments.go#L104 - loadOwnedAdjustment: foreign locator 404s (L119)
  15. billing/internal/service/installment.go#L73 - CreateSchedule; frequency→installments map L16-L20; no invoice generator
  16. billing/internal/service/installment.go#L145 - CancelSchedule → CANCELLED + installment.cancelled
  17. billing/internal/repository/gorm_installments.go#L35 - ListByAccount (unexposed) + Cancel status write
  18. billing/internal/service/locator.go#L31 - Generate: PREFIX-YEAR-%06d in-process counter (used by ISC/LED, not ADJ)
  19. billing/internal/handler/installments.go#L16 - createInstallmentHandler: OwnsAccount 403, nil svc → 501
  20. billing/internal/handler/quote_payments.go#L15 - QuotePaymentStore interface + read route (internal, no member auth)
  21. billing/internal/handler/quote_payments.go#L45 - markQuotePaidHandler: mock/test settle path (X-Internal-Service-gated)
  22. billing/internal/handler/quote_payments.go#L75 - getQuotePaymentHandler:
  23. billing/internal/repository/gorm_quote_payments.go#L35 - MarkQuotePaid: upsert on quote_locator + quote.payment_settled outbox
  24. billing/internal/repository/gorm_quote_payments.go#L67 - MarkQuoteFailed: ON CONFLICT … WHERE status <> 'SETTLED' never-downgrade guard
  25. billing/internal/handler/stripe_webhook.go#L121 - handlePaymentIntentSucceeded; D2C quoteLocator branch L131-L138
  26. billing/internal/handler/stripe_webhook.go#L175 - handlePaymentIntentFailed: D2C-only, marks FAILED, does not open the gate
  27. billing/internal/handler/stripe_webhook.go#L40 - webhook fails closed without STRIPE_WEBHOOK_SECRET
  28. billing/internal/handler/handler.go#L424 - adjustment routes (create/list/get/apply/reverse)
  29. billing/internal/handler/handler.go#L448 - installment routes (create/cancel only)
  30. billing/internal/handler/handler.go#L341 - internal quote-payment read + X-Internal-Service-gated settle route
  31. billing/cmd/server/main.go#L86 - adjustment / installment / quote-payment repository wiring
  32. billing/cmd/server/main.go#L244 - installment service (WithOutbox) + Deps wiring (QuotePayments)
  33. enrollment/internal/service/quote.go#L788 - IssueQuote payment gate: fail-closed on unpaid prepaid quote (#1675)
  34. enrollment/internal/client/billing.go#L36 - QuotePaymentStatus over GET /internal/quotes/{q}/payment
  35. packages/go/domain/tablenames_test.go#L43 - the only AutopayPreference reference outside the model
  36. billing/internal/service/billing.go#L16 - billingEventsTopic = "billing.events", the outbox topic for adjustment/installment events
  37. billing/internal/repository/gorm_accounts.go#L88 - SaveCard: stripe_customer_id / stripe_payment_method_id + brand/last4/expiry on the account (the instrument an autopay run would charge)
  38. billing/internal/handler/payment_method.go#L54 - the saved-card read: {onFile, brand, last4, expMonth, expYear} off the account
  39. billing/cmd/server/main.go#L223 - how a billing background job is registered: lapse ticker always on, delinquency ticker behind BILLING_DELINQUENCY_JOB

Live-schema facts (row counts, status/type/frequency/currency distributions, the worked adjustment's ledger entry, Debezium publication membership) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d billing · \d billing.adjustments, \d billing.installment_schedules, \d billing.autopay_preferences, \d billing.quote_payments, select type, status, count(*) from billing.adjustments group by 1,2, select status, count(*) from billing.quote_payments group by 1, select currency, count(*) from billing.quote_payments group by 1, select tablename from pg_publication_tables where pubname='dbz_billing', 2026-08-21.

Olly Health Insurance Platform