EXECUTION
Applystripe Convergence
Three systems write bad debt independently. MR !9716 makes applystripe the single execution path — three gaps, nine commits (wire-before-activate discipline).
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.
| Aspect | Isolated Handlers (Before) | Brain (After) |
|---|---|---|
| State reading | Each handler reads what it needs | Single Gather reads everything once |
| Decision making | Scattered across handler branches | Centralized Evaluate+Delta+Decide |
| Skip logic | Inconsistent per handler | Single policy evaluation |
| Side effects | Interleaved with decisions | Separated into Consequence execution |
| Testability | Must mock Stripe + IAPI + DB | Pure 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.
| Operation | applystripe | rpc_server | Brain needs | Gap |
|---|---|---|---|---|
| DB flag bad debt (subs_account) | ✅ | ✅ | ✅ | None |
| DB unflag bad debt | ✅ | ✅ | ✅ | None |
| DB customer bad_debt (subs_customer) | ✅ | ✅ | ✅ | None |
| Stripe customer metadata (bad_debt) | ✅ | ✅ | ✅ | Routed through billingProfileSvc |
| IAPI ban | ✅ | ✅ | ✅ | None |
| IAPI unban | ✅ | ✅ | ✅ | None |
| IAPI remove DNU | ✅ | ✅ | ✅ | None |
| Cancel subscription | ✅ | ❌ | ✅ | None |
| Set invoice bad_debt_amount metadata | ✅ | ❌ | ✅ | None |
| Mark invoice bad_debt_paid | ✅ | ❌ | ✅ | None |
| Send bad debt email | ❌ | ❌ | ✅ | emails.Client wired into applystripe |
| Apply credit (balance txn) | ❌ | ❌ | ❌ | N/A — not part of dunning path |
| Close original invoices in invoice-repo | ❌ | ❌ | ✅ | invoicerepo.Client wired into applystripe |
| Disable invoice paylinks | ❌ | ❌ | ✅ | invoicerepo.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.
trySetCustomerMetadata now routes the bad_debt key through billingProfileSvc.SetBadDebtOnCustomer for cache invalidation. Remaining non-bad_debt keys still use raw stripeExt.
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.
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.
| Invoice | Stripe Status | Generic Sync | Dunning Handler |
|---|---|---|---|
| Consolidated bad-debt invoice | paid | Closes it in invoice-repo ✅ | N/A |
| Original uncollectible invoices | uncollectible (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.
| # | Category | Change | Hunk types |
|---|---|---|---|
| 1 | CORE | Route bad_debt metadata through billingProfileSvc | FUNCTIONAL — 6 tests |
| 2 | REFACTOR | Wire emails.Client into applystripe | WIRING — dead dep, no callers |
| 3 | REFACTOR | Scaffold sendBadDebtEmail + HostedInvoiceURL | SCAFFOLD — new method + field, no callers |
| 4 | CORE | Activate email on bad debt flag path | FUNCTIONAL — 11 tests |
| 5 | REFACTOR | Wire invoicerepo.Client into applystripe | WIRING — dead dep, no callers |
| 6 | REFACTOR | Scaffold invoice-repo close + disable + plan fields | SCAFFOLD — new method + struct, no callers |
| 7 | CORE | Activate invoice-repo lifecycle on lift | FUNCTIONAL — 7 tests |
| 8 | REFACTOR | Fix gofmt import ordering | FORMAT |
| 9 | CORE | Add nil-guard on emailClient | GUARD — defensive consistency |
Three gaps, nine commits, 24 new tests. After MR !9716, applystripe handles every bad debt write operation the brain needs — metadata, email, DB flags, IAPI bans, subscription cancellation, and invoice-repo document lifecycle. Next: extract dunning.Service and activate the brain.