Billing lifecycle: adjustments, installments & autopay
Schema deep-dive · living document · #14 in the reading sequence
| Tables | billing.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 service | billing (sole writer) |
| Locators | ADJ-<uuid> (adjustments) · ISC-YYYY-NNNNNN (installment schedules); autopay & quote_payments are natural-key (policy_id / quote_locator) |
| Last updated | 2026-08-28 |
| Companion | ERD 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 in | Join |
|---|---|---|
| a charge | billing.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 payment | billing.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 split | a 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 engine | autopay_preferences.enabled is the stored intent; the autopay run that reads it is the extension point named in §9 | - |
| the policy | enrollment.policies; enrollment reads the quote-payment gate before issuing, then owns the policy[33] | quote_locator (soft, cross-service) |
| the Stripe object | Stripe 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
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / 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_id | uuid | ✓ | The account credited/debited; no FK. Tenancy is enforced on it (§4) |
policy_id | uuid | Nullable in DB; the create handler mandates it, and the Go field is a non-pointer uuid, so a live row always carries one[13] | |
type | text | ✓ | CREDIT | 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 / currency | numeric(14,4) / char(3) | ✓ | Platform money shape; currency default 'GBP' |
reason | text | Free text ("goodwill"); copied to the ledger entry's description | |
status | text | ✓ | Default 'PENDING'; PENDING → APPLIED → REVERSED (convention) |
applied_at | timestamptz | Stamped when APPLIED; NULL until then | |
created_at | timestamptz | ✓ | Bookkeeping |
installment_schedules
| Field | Type | Req | Notes |
|---|---|---|---|
id / locator | uuid / text | ✓ | PK / UNIQUE. ISC-YYYY-NNNNNN from the in-process counter[18] |
account_id | uuid | ✓ | The account to bill; indexed (idx_installment_account), no FK |
policy_id | uuid | Nullable; Go field is *uuid.UUID here (a genuine optional, unlike adjustments) | |
total_amount / currency | numeric(14,4) / char(3) | ✓ | The sum to split |
frequency | text | ✓ | monthly | quarterly | annually - the only accepted values[15] |
installments | int | ✓ | Derived from frequency, not supplied: monthly→12, quarterly→4, annually→1[15] |
start_date | date | ✓ | When the plan begins - the first due date the generator reads (§9) |
status | text | ✓ | Default '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_at | timestamptz | ✓ | Bookkeeping |
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
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
policy_id | uuid | ✓ | UNIQUE - one row per policy; the intended key (no locator) |
enabled | boolean | ✓ | NOT NULL default false - the per-policy on/off the collection run reads (§9) |
method | text | Which instrument to collect with; nullable, and bare text, so a new method (card, direct debit, open banking) is a value not a migration | |
updated_at | timestamptz | ✓ | Bookkeeping |
quote_payments
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
quote_locator | text | ✓ | UNIQUE - the idempotency key; the enrollment gate reads by it[34] |
stripe_payment_intent_id | text | The settling PaymentIntent; indexed (idx_quote_payments_pi) | |
amount_pence | bigint | ✓ | Pence, not numeric(14,4) - straight from Stripe's integer minor units (§3 below) |
currency | char(3) | ✓ | Default 'GBP'; live rows carry Stripe's lowercase 'gbp' (defect, §9) |
status | text | ✓ | SETTLED | FAILED (no PENDING; a row is only written on a terminal Stripe outcome) |
settled_at | timestamptz | Set on SETTLED; NULL on FAILED | |
failure_reason | text | Set on FAILED, from Stripe's last_payment_error (added by 0020)[4] | |
party_locator | text | The D2C member the payment/failure targets (added by 0020) | |
created_at | timestamptz | ✓ | Bookkeeping |
3.1 ENG-454: the schedule of record and its reconciliation (pending merge)
Branch
feat/eng-454-recurring-premium, PR #1750. Cited as plainpath:linebecause the code is not onmain; 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).
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
schedule_id | uuid | ✓ | FK installment_schedules(id) ON DELETE CASCADE - the first real FK in this family (the schedule's own account_id is still soft) |
seq | int | ✓ | 1..N; the lowest not-yet-PAID row is the one a cycle settles |
due_date | date | ✓ | Stepped from start_date by frequency (monthly/quarterly/annually) |
amount / currency | numeric(14,4) / char(3) | ✓ | Per-cycle amount in major units, unlike quote_payments.amount_pence (§9) |
status | text | ✓ | SCHEDULED -> ATTEMPTING -> PAID | FAILED | PAST_DUE |
stripe_invoice_id | text | The Stripe invoice that settled this instalment; indexed. The reconciliation join key | |
stripe_payment_intent_id | text | The cycle's PaymentIntent, which is also the ledger write's idempotency key | |
paid_at | timestamptz | Set on PAID | |
created_at, updated_at | timestamptz | ✓ | Bookkeeping |
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.
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK |
schedule_id / schedule_locator / party_locator | uuid / text / text | Whose drift it is | |
drift_type | text | ✓ | UNEXPECTED_CHARGE | UNCOLLECTED_INSTALMENT | AMOUNT_MISMATCH | OVER_COLLECTION |
seq, stripe_invoice_id | int, text | Which instalment / which Stripe invoice | |
expected_amount, observed_amount | numeric(14,4) | ✓ | The two sides, side by side |
amount_delta | numeric(14,4) | ✓ | Absolute money at risk; this column is the SLO numerator |
status | text | ✓ | OPEN | RESOLVED |
first_detected_at / last_detected_at | timestamptz | ✓ | first_detected_at drives the settling window: a mismatch younger than it is in-flight, not drift |
resolved_at / resolved_by / resolution_note | timestamptz / text / text | The 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/.adjudicatedevent 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
| Invariant | Enforced by |
|---|---|
adjustments.locator, installment_schedules.locator unique | DB UNIQUE[1][2] |
| One autopay row per policy | DB 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 FAILED | Application: ON CONFLICT … WHERE status <> 'SETTLED' on the failure path[24] |
| Adjustment apply is legal only from PENDING; reverse only from APPLIED | Application: the repository asserts the from-status before updating[12] |
| Applying/reversing an adjustment posts its ledger entry atomically | Application: status flip + ledger append + outbox in one runInTx[9][10] |
frequency is one of monthly/quarterly/annually | Application: unknown frequency → 422, no row written[15] |
| A tenant only touches its own account's adjustments / schedules | Application: 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_payment | Application: webhook fails closed without the signing secret; settle-by-hand is X-Internal-Service-gated[27][30] |
type, status, method, frequency vocabularies | Nothing 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 CDC | Debezium 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):
{
"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"
}| Key | Read by | What actually happens |
|---|---|---|
type: CREDIT | ApplyAdjustment[9] | maps to entry_type=ADJUSTMENT_CREDIT, direction=CREDIT on the ledger entry |
policy_id: b87710ed-… | copied onto the ledger entry | the 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 guard | reverse is now legal; a second apply is rejected (not PENDING) |
account_id | tenancy | the 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.0000Live 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):
{
"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:
{
"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": ""
}| Key | Read by | What actually happens |
|---|---|---|
quote_locator: QTE-2026-001149 | enrollment issuance gate[34] | GET /internal/quotes/QTE-2026-001149/payment → {paid: true}, so IssueQuote may proceed |
amount_pence: 3703 | nothing downstream | £37.03, stored in Stripe's integer minor units, not numeric |
currency: "gbp" | display only | lowercase - 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-001208 | outbox quote.payment_settled | the 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:
{ "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
| Where | Column / mechanism | Meaning 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] |
| enrollment | GET /internal/quotes/{q}/payment (issuance gate) | reads the D2C quote's settled status before turning it into a policy[33][34] |
| Novu / notifications | quote.payment_settled / quote.payment_failed on billing.events | the D2C welcome / payment-failed notification pipeline[23] |
| document-service / consumers | adjustment.*, installment.* on billing.events | outbox announcements; no dedicated consumer acts on them today |
| employer app / web-admin / Ollyverse | GET/POST /billing/adjustments…, POST /billing/installments… (scoped by org_locator)[28][29] | ops raises credits / schedules; tenancy-scoped |
autopay_preferences | reserved for the autopay collection run | its 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
- An adjustment is a ledger movement, not an invoice line. Applying one posts an
ADJUSTMENT_CREDIT/ADJUSTMENT_DEBITentry directly; reversing posts aREVERSALin the opposite direction. The status machine and the ledger entry move in one transaction[9]. - The D2C deposit is quote-scoped, not account-scoped. A member pays before a policy/account/invoice exists, so
quote_paymentskeys onquote_locator+party_locatorand lives outsidebilling.paymentsentirely. #1675[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].
- 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]. quote_paymentsis 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 indbz_billing(§4, §9).- 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].
- 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,methodandfrequencyare baretext, 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 add | Where |
|---|---|---|
| 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 card | services/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-monthly ✅ delivered by ENG-454, pending merge | Rather 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 billing | ALTER PUBLICATION dbz_billing ADD TABLE billing.quote_payments, then let the Debezium connector pick the new table up | the 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 case | adjustments.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 CREDIT | ApplyAdjustment 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 events | quote.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 buildsADJ-<raw uuid>with an inlinefmt.Sprintf[13] while every other billing locator (CHG-,INV-,PAY-,LED-,ISC-) comes from thePREFIX-YEAR-%06dcounter[18] - two minting schemes in one service, and adjustment locators that no ops tool can sort or guess. Fix: callLocatorGenerator.Generate("ADJ")inservices/billing/internal/handler/adjustments.go, and backfill or accept the 17 existing uuid-form locators. - Normalise
quote_payments.currencyto upper case. The Stripe webhook passespi.Currencythrough 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 inservices/billing/internal/handler/stripe_webhook.go, plus a one-offUPDATE billing.quote_payments SET currency = upper(currency). - Reconcile
amount_pencewith the platform money shape. It is abigintin Stripe's minor units while every other money column in billing isnumeric(14,4)[3], so any reader summing money across billing tables has to know to divide this one by 100. Fix: either add a generatednumeric(14,4)amount column in a billing migration, or convert once at the read boundary inservices/billing/internal/handler/quote_payments.goand state the unit in the response[22]. - Align
adjustments.policy_idnullability 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.
billing/migrations/0006_create_adjustments.sql- adjustments DDL; UNIQUE locator L4billing/migrations/0014_billing_lifecycle.sql- installment_schedules L10-L24 (account index L24) · autopay_preferences L27-L33 (UNIQUE policy_id L29)billing/migrations/0019_create_quote_payments.sql- quote_payments DDL + rationale (D2C, keyed on quote_locator, #1675)billing/migrations/0020_quote_payment_failure.sql- failure_reason + party_locator (payment-declined path, #1675)packages/go/domain/billing.go#L157- Adjustment model (type CREDIT|DEBIT, status PENDING|APPLIED|REVERSED)packages/go/domain/billing.go#L204- InstallmentSchedule model (PolicyID *uuid pointer)packages/go/domain/billing.go#L229- AutopayPreference model (the only definition; the collection run of §9 is its reader)packages/go/domain/billing.go#L129- QuotePayment model + the "before any policy/invoice exists" rationalebilling/internal/service/billing.go#L322- ApplyAdjustment: type→direction map, ledger append + outbox in one tx, real policy_id copiedbilling/internal/service/billing.go#L387- ReverseAdjustment: REVERSAL entry, opposite directionbilling/internal/service/billing.go#L572- CreateAdjustment: PENDING row + adjustment.created outboxbilling/internal/repository/gorm_adjustments.go#L60- Apply (asserts PENDING) / Reverse (asserts APPLIED) status guardsbilling/internal/handler/adjustments.go#L26- createAdjustmentHandler:ADJ-<uuid>locator L55, OwnsAccount 403 L46, policy_id mandated L50-L54billing/internal/handler/adjustments.go#L104- loadOwnedAdjustment: foreign locator 404s (L119)billing/internal/service/installment.go#L73- CreateSchedule; frequency→installments map L16-L20; no invoice generatorbilling/internal/service/installment.go#L145- CancelSchedule → CANCELLED + installment.cancelledbilling/internal/repository/gorm_installments.go#L35- ListByAccount (unexposed) + Cancel status writebilling/internal/service/locator.go#L31- Generate:PREFIX-YEAR-%06din-process counter (used by ISC/LED, not ADJ)billing/internal/handler/installments.go#L16- createInstallmentHandler: OwnsAccount 403, nil svc → 501billing/internal/handler/quote_payments.go#L15- QuotePaymentStore interface + read route (internal, no member auth)billing/internal/handler/quote_payments.go#L45- markQuotePaidHandler: mock/test settle path (X-Internal-Service-gated)billing/internal/handler/quote_payments.go#L75- getQuotePaymentHandler:billing/internal/repository/gorm_quote_payments.go#L35- MarkQuotePaid: upsert on quote_locator + quote.payment_settled outboxbilling/internal/repository/gorm_quote_payments.go#L67- MarkQuoteFailed:ON CONFLICT … WHERE status <> 'SETTLED'never-downgrade guardbilling/internal/handler/stripe_webhook.go#L121- handlePaymentIntentSucceeded; D2C quoteLocator branch L131-L138billing/internal/handler/stripe_webhook.go#L175- handlePaymentIntentFailed: D2C-only, marks FAILED, does not open the gatebilling/internal/handler/stripe_webhook.go#L40- webhook fails closed without STRIPE_WEBHOOK_SECRETbilling/internal/handler/handler.go#L424- adjustment routes (create/list/get/apply/reverse)billing/internal/handler/handler.go#L448- installment routes (create/cancel only)billing/internal/handler/handler.go#L341- internal quote-payment read + X-Internal-Service-gated settle routebilling/cmd/server/main.go#L86- adjustment / installment / quote-payment repository wiringbilling/cmd/server/main.go#L244- installment service (WithOutbox) + Deps wiring (QuotePayments)enrollment/internal/service/quote.go#L788- IssueQuote payment gate: fail-closed on unpaid prepaid quote (#1675)enrollment/internal/client/billing.go#L36- QuotePaymentStatus overGET /internal/quotes/{q}/paymentpackages/go/domain/tablenames_test.go#L43- the only AutopayPreference reference outside the modelbilling/internal/service/billing.go#L16-billingEventsTopic = "billing.events", the outbox topic for adjustment/installment eventsbilling/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)billing/internal/handler/payment_method.go#L54- the saved-card read:{onFile, brand, last4, expMonth, expYear}off the accountbilling/cmd/server/main.go#L223- how a billing background job is registered: lapse ticker always on, delinquency ticker behindBILLING_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.
