Three systems execute bad debt writes independently: applystripe (cron/admin sync), rpc_server (manual RPC), and billing-webhooks (real-time dunning). The brain should not become a fourth — it converges on applystripe.

The key architectural shift: isolated handlers each read partial state and make local decisions. The brain centralizes all reads, evaluates holistically, and emits a complete set of consequences. It delegates all writes to applystripe.ApplyElementsToStripe.

READ — Gather all state from 6 stores EVALUATE — Apply dunning rules DELTA — Compare current vs desired DECIDE — Emit typed Consequences EXECUTE — Delegate to applystripe
AspectIsolated Handlers (Before)Brain (After)
State readingEach handler reads what it needsSingle Gather reads everything once
Decision makingScattered across handler branchesCentralized Evaluate+Delta+Decide
Skip logicInconsistent per handlerSingle policy evaluation
Side effectsInterleaved with decisionsSeparated into Consequence execution
TestabilityMust mock Stripe + IAPI + DBPure function: State → Consequences

Every bad debt write operation the brain needs, mapped against what applystripe and rpc_server already support. Three rows have gaps — these are the three changes this MR makes.

Operationapplystriperpc_serverBrain needsGap
DB flag bad debt (subs_account)None
DB unflag bad debtNone
DB customer bad_debt (subs_customer)None
Stripe customer metadata (bad_debt)Routed through billingProfileSvc
IAPI banNone
IAPI unbanNone
IAPI remove DNUNone
Cancel subscriptionNone
Set invoice bad_debt_amount metadataNone
Mark invoice bad_debt_paidNone
Send bad debt emailemails.Client wired into applystripe
Apply credit (balance txn)N/A — not part of dunning path
Close original invoices in invoice-repoinvoicerepo.Client wired into applystripe
Disable invoice paylinksinvoicerepo.Client wired into applystripe

Two initially-identified concerns are non-issues. Credit application (balance transactions) is not part of the dunning path — billing-webhooks never applies credits during bad debt processing. Lift decision equivalence is already covered: trySetBadDebtFlagOnAccount matches all 5 operations, confirmed by 6 existing applicator tests.

1. Cache-aware Stripe metadata ✅

trySetCustomerMetadata now routes the bad_debt key through billingProfileSvc.SetBadDebtOnCustomer for cache invalidation. Remaining non-bad_debt keys still use raw stripeExt.

2. Email notification ✅

emails.Client wired into applystripe. sendBadDebtEmail fires on the flag path, reading HostedInvoiceURL from the execution plan (no redundant Stripe API call). Errors are non-fatal.

3. Invoice-repo document lifecycle ✅

invoicerepo.Client wired into applystripe. closeOriginalInvoicesInRepo calls ModifyInvoice(closed=true) and DisableInvoiceLinks on originals during bad debt lift. BillingID (UCID) captured from customer lookup during Gather.

Why Originals Need Separate Closing

When the consolidated bad-debt invoice is paid, generic Stripe event sync closes it in invoice-repo. But original uncollectible invoices never change status in Stripe — they stay uncollectible forever. Only the dunning handler knows to cross-reference the consolidated invoice's bad_debt_invoices metadata and close the originals. This is a dunning decision, not event sync.

InvoiceStripe StatusGeneric SyncDunning Handler
Consolidated bad-debt invoicepaidCloses it in invoice-repo ✅N/A
Original uncollectible invoicesuncollectible (forever)Sets Closed=false ❌Closes + disables paylinks ✅

Each REFACTOR commit introduces dead infrastructure (WIRING or SCAFFOLD — no callers, no behavior change, no assertion changes). Each CORE commit is the irreducible behavioral activation with corresponding tests. Wire before activate.

#CategoryChangeHunk types
1CORERoute bad_debt metadata through billingProfileSvcFUNCTIONAL — 6 tests
2REFACTORWire emails.Client into applystripeWIRING — dead dep, no callers
3REFACTORScaffold sendBadDebtEmail + HostedInvoiceURLSCAFFOLD — new method + field, no callers
4COREActivate email on bad debt flag pathFUNCTIONAL — 11 tests
5REFACTORWire invoicerepo.Client into applystripeWIRING — dead dep, no callers
6REFACTORScaffold invoice-repo close + disable + plan fieldsSCAFFOLD — new method + struct, no callers
7COREActivate invoice-repo lifecycle on liftFUNCTIONAL — 7 tests
8REFACTORFix gofmt import orderingFORMAT
9COREAdd nil-guard on emailClientGUARD — defensive consistency