StatusDRAFT — contingent on credit note surface audit (M1) and brain expansion Phase 1
DriverJared Goguen
ReviewersFinTech Engineering Review — Mar 30
Repossubscriptions-api (credit notes, brain destination), billing-webhooks (brain source)
Staffing2 engineers, 1 quarter
DependenciesBrain Phase 2 (flag lifting) enables credit note M3 (pay-original-invoice)

When payment fails and the invoice goes uncollectible, the user ends up with two invoices in their dashboard: the original they recognize ($25.47) and a synthetic "consolidated" one they don't ($19.66). Two buttons, one confusing. Meanwhile, four handlers in billing-webhooks race to make dunning decisions with partial state, and five stores disagree on what the dunning state actually is.

The Invoice Lies

The invoice shows $25.47. The real debt is $19.66, hidden in bad_debt_amount metadata. Five Stripe metadata keys govern bad-debt state — metadata is not accounting.

Two Buttons

Two API endpoints return overlapping data. GetBillingBadDebt returns prorated amounts. GetAccountBillingHistory returns original amounts. The dashboard shows both.

Racing Code Paths

Four handlers in billing-webhooks make dunning decisions with partial state. They race with the forwarding path that sends events to the engine. Decisions split across two services with no coordination.

Five-Store Drift

subs_dunning, subs_customer, subs_account, Stripe metadata, and IAPI ban state — five stores hold dunning-related state that must agree. They don't always. 90 tickets in 12 weeks, stable volume.

Two forward changes, one retroactive migration. First, issue a credit note for unused service at uncollectible time — the invoice amount adjusts to the real debt. Second, move dunning logic into the brain — one service, full state, coordinated decisions. Third, migrate 73,517 existing accounts from metadata to credit notes. Together: the invoice shows the truth, and the system responds correctly when it's paid.

Original Invoice $200 recognizable USER SEES THIS
Hidden Metadata bad_debt_amount proration-calc BURIED IN STRIPE
Consolidated Invoice $32.26 synthetic SYSTEM CREATES
Credit Note $167.74 26-unused-days PROPOSED: HONEST
Adjusted Invoice $32.26 same-invoice ONE INVOICE, TRUE AMOUNT
One Button pay-original USER PAYS WHAT THEY OWE
today: real debt hidden today: synthetic invoice proposed: credit note issued invoice amount adjusts user pays adjusted amount
MomentTodayHonest
T0Invoice created: $25.47 ($20 Pro + $5.47 Workers). Payment fails.Same — both systems agree.
T2Prorate and encode to metadata. Write bad_debt_amount=$19.66. Invoice still shows $25.47.Issue credit note for $5.81 (9 unused days of $20/mo Pro). Invoice adjusts to $19.66.
T3Dead period. No service. No payment.Same — both systems agree.
T4Create consolidated invoice summing bad_debt_amount values. Link via metadata. User sees two invoices.User pays the original invoice at $19.66. One button. The invoice they recognize.
TodayAfter
What user sees$32.26 on an invoice they've never seenTheir $200 invoice → $167.74 credit → pay $32.26
Payment targetSynthetic consolidated Stripe invoiceOriginal Stripe invoice (amount_remaining adjusted)
Audit trailconsolidated → bad_debt_invoices → originalsinvoice → credit_note → payment
Drift detectionbad_debt_amount correct across 4 stores?Credit note exists? amount_remaining matches proration?
Revenue recognitionCustom bad_debt_amount handler + CN pipelineCN pipeline only (credit_note_processor.go)
AutomationPartial — flag lift may need manual interventionFull — pay → lift → new sub (Brain Phase 2)

Three credit note issuance paths already exist in subscriptions-api, plus an approval pipeline and revenue recognition processor. We're adding a fourth path — triggered when an invoice is marked uncollectible.

1. Classify

Each line item: in-advance (fixed-cost, future period) or in-arrears (usage, past consumption). Usage charges get no credit — they're owed in full.

2. Calculate

For each in-advance item: (days_remaining / total_days) × amount. Sum to get the credit note total.

3. Issue

Stripe API: POST /v1/credit_notes. Reason: order_change. Line items reference originals. Tax credited proportionally.

4. Adjust

amount_remaining adjusts from $25.47 to $19.66. That number is the debt. One button. Payable.

Reinstatement: user sees original invoice at adjusted amount, pays via Unified Checkout, brain lifts the bad-debt flag (Phase 2), new subscription starts with a fresh billing cycle anchor.

11 surfaces read or write bad debt metadata. Each one changes or disappears. M1 (surface audit) must produce a complete manifest before implementation begins.

SurfaceR/WWhat Changes
GetBillingBadDebt APIRCreates consolidated invoices from bad_debt_amount. Replaced by credit-note-adjusted originals.
GetAccountBillingHistoryRDisplays all invoices including consolidated. Consolidated invoices disappear.
stripe_invoice_utils.goRUses bad_debt_paid to transform uncollectible → OPEN/CLOSED. Needs credit note awareness.
Calculator.GetBadDebtAmountRPriority chain reading 5 metadata keys. Replaced by credit note amount_remaining.
createConsolidatedInvoiceWDedup via bad_debt_invoices metadata. Entire path deleted.
FlagAsBadDebt handlerWWrites bad_debt_amount at uncollectible time. Replaced by credit note issuance.
CheckFlagAsBadDebt / badDebtLiftRReads bad_debt_paid for lifting. Needs credit-note-aware lift trigger.
Drift detection (BQ)RChecks bad_debt_amount correctness. Replaced by credit note existence + amount check.
HealAccount / applystripeRReconciliation must understand credit-note-adjusted invoices are not drifting.
Dashboard payment flowRRoutes to consolidated vs original. With credit notes, always routes to original.
Revenue recognitionRcredit_note_processor.go already handles CNs. New issuance path feeds into existing pipeline.

Honest invoicing changes what the payment target is. The dunning brain changes how the system responds when it's paid. Four handlers in billing-webhooks make dunning decisions with partial state. The work: move them into subscriptions-api, pin their behavior with tests, reshape into the brain's architecture, delete the originals.

4
handlers to move from billing-webhooks
20
test scenarios across flagging, lifting, and amount calculation
6
state stores the brain reads for full context
3
RPCs that become direct calls (hop eliminated)
Phase 1 · Copy + Test (4–6 weeks)

Move 4 handlers + supporting functions into subs-api. Three RPCs become direct calls. 20 integration tests pin every branch across flagging, lifting, and amount calculation. Copy and test overlap.

Phase 2 · Redesign (4–6 weeks)

Read-evaluate-delta-decide. Idempotent. Incremental: flagging first, then lifting, then account type rules. Largest phase.

Phase 3 · Delete (1–2 weeks)

Code audit, verify test coverage, delete originals. billing-webhooks becomes a pure forwarder.

The redesign target. Old handlers make isolated decisions with partial state. The brain reads everything, evaluates per-type rules, computes the minimal delta, and produces one coordinated set of consequences.

Stripe Events payment-failed marked-uncollectible invoice-paid TRIGGERS
Read State subs-dunning subs-account subs-customer invoices subs-mandate subs-stuck 6 STORES
Evaluate Rules account-type segment history PER-TYPE
Compute Delta current-vs-desired idempotent minimal-diff DELTA
Decide flag ban cancel unflag notify CONSEQUENCES
triggers full context rules applied minimal diff
WorkEstimateStaffingNotes
Phases 1–3 (critical path)10–14 weeks1 engineerSequential. Copy + Test → Redesign → Delete.
Segment Rules3–4 weeks1 engineerDiscovery parallel from w1. Impl after Phase 3.
PayPal spike1–2 weeks1 engineerParallel. Bounds brain scope early.

Separate workstream, starts in parallel with brain expansion. Audit all account type handling scattered across the codebase, define per-type dunning rules with Product and Finance, implement in the brain. Unknown types error loudly — no silent PayGo defaults.

12
account types with full dunning treatment today
16
types excluded from dunning (need explicit rules)
0
tolerance for unknown types falling through silently

The migration population is every account with uncollectible invoices where debt is encoded as Stripe metadata instead of credit notes — 73,517 accounts as of March 2026. The forward mechanism (M1–M4) ships first; the migration (M5) fixes the existing stock.

73,517
accounts with lying invoices
~9,000
in active drift at any time
Late Q2
after M4 proves the mechanics

Sizing inputs are gathered during M1–M4: reinstatement rate by account age, Stripe credit note behavior on uncollectible invoices, and the brain's coverage of legacy metadata accounts. These determine batch strategy and timeline.

Four execution paths based on account state. Sub-cohort sizes are produced by the Analyze & Size phase.

Clean single-invoice

1 uncollectible invoice, no consolidated, no override. Largest sub-cohort. Bulk migration.

Clean multi-invoice

2+ uncollectible invoices. Credit note per invoice. Multi-invoice policy required first.

Has consolidated invoice

User already attempted payment. Void consolidated, credit note originals.

Manual override

bad_debt_note set by support. Individual review. Not bulk-migrated.

Drift detection is where the complexity lives. The unified inconsistency report (cloudflare_account_inconsistencies) unions four sources. Three check flags — they're untouched. One checks amounts — it's replaced entirely. Three new invariants from this workstream, each enforced at three tiers.

Three of four drift sources (subscription, flags, never-paid) are unchanged — they check flags and payment status, not amounts. The fourth — invoice metadata drift — is replaced entirely: was bad_debt_amount correct across 4 stores? Now: credit note exists + amount_remaining matches proration?

#InvariantProperty
I2Every uncollectible has a credit noteFor every invoice in uncollectible status (post-migration), a credit note exists. amount_remaining = original − credit.
I3Blocked customer has recovery pathEvery account with bad_debt flag has at least one payable invoice. Payment clears all flags.
I5Dunning state consistent across storessubs_dunning, subs_customer, subs_account, Stripe metadata, and IAPI ban state agree on dunning status.
I2 · Enforcement

Inline: uncollectible handler issues CN atomically. Batch: BQ query for uncollectible invoices without CNs (post-migration). Alert: counter — PagerDuty if > 0.

I3 · Enforcement

Inline: brain verifies payable invoice exists before flagging. Batch: BQ query for flagged accounts without recovery path. Alert: counter — PagerDuty if > 0.

I5 · Enforcement

Inline: recompute engine reads all 6 stores before writes. Batch: existing BQ consistency check. Alert: inconsistency count trending up.

2
engineers
1
quarter
10
milestones across 3 tracks
W1
W2
W3
W4
W5
W6
W7
W8
W9
W10
W11
W12
W13
W14
Dunning Brain
Brain · Replicate Logic
Brain · Shadow Test
Brain · Redesign Engine
Brain · Delete Legacy
Brain · Segment Rules
Credit Notes + Migration
Credit Notes · Surface Audit
Credit Notes · Issuance
Credit Notes · Pay-Original
Credit Notes · Validate
Migration · Analyze & Size
Migration · Pilot
Migration · Bulk Run
Migration · Verify & Close
DeliverableShipsDepends OnΔ/moEstimate
Brain Phase 1 · Copy + TestApril4–6 weeks
CN M1 · Surface AuditApril1–2 weeks
CN M2 · IssuanceApr–MayCN M12–3 weeks
Brain Phase 2 · RedesignMayBrain Phase 14–6 weeks
CN M3 · Pay-OriginalMayCN M2, Brain Phase 22–3 weeks
CN M4 · ValidationJunCN M32–3 weeks
Brain Phase 3 · DeleteJunBrain Phase 21–2 weeks
Segment RulesJulBrain Phase 33–4 weeks
Retroactive MigrationJunCN M43–4 weeks (73K accounts)
Brain + CN + CleanupAprilBrain Phase 1−100(stale flag cleanup)
#DecisionOwnerBy WhenStatusRecommendationRisk if Wrong
1Surface audit completenessEngineeringApr 1 (CN M1)OPENCode search + brain graphCritical — silent break
2Multi-invoice reinstatement policyProduct + FinanceMay 12 (CN M3)OPENProgressive (latest first)High — delays M3
3BCA reset vs. inherit old anchorEng + ProductMay 12 (CN M3)OPENResetLow — comms needed
4Credit note reason codeEngineeringApr 14 (CN M2)DECIDEDorder_changeLow
5PayPal dunning logic scopeEngineeringApr 1 (Brain P1)OPENSpike to bound scopeHigh — scope expansion
6Per-type dunning rulesProduct + FinanceJun 30 (Segments)OPENExplicit rules per typeMedium — business rules
7Retroactive CN accounting limitsFinanceJun 16 (Migration)OPENResearch Stripe limitsMedium — old invoices
8Dual-mode coexistence during migrationEngineeringJun 2 (CN M4)OPENCN first, fall back to metadataMedium — inconsistent
RiskImpactOwnerMitigation
Undiscovered handlersCriticalCN M1 leadSurface audit (CN M1) + code search. Any missed handler breaks silently.
Premature deletionCriticalBrain leadBrain Phase 3 gated on Phase 2 test suite passing. No deletion without coverage.
PayPal scope expansionHighBrain leadSpike bounds brain scope before Phase 1. If PayPal logic is larger than expected, brain timeline grows.
Stripe CN limits on uncollectibleHighCN leadLoad-bearing unknown. If Stripe rejects CNs on uncollectible invoices, fallback is 1:1 replacement invoices. Fallback adds ~2 weeks.
Business rule disagreementMediumProductPer-type rules need Product + Finance alignment. Start discovery in parallel.
WhatWhy
Payment retry logicDistinct concern from invoice adjustment and brain architecture. Stays in billing-webhooks.
Notification systemDunning notifications are downstream of brain decisions. Separate service, separate timeline.
Billing portal surfacesDashboard changes are downstream of the API work. Separate surface, separate timeline.
Probation surfacesProbation is policy enforcement. Brain doesn't change probation behavior.
Shadow billing / ReactorSeparate workstream. Validates the billing engine, not dunning or invoicing.

Code inventory (4 handlers), test matrices (20 scenarios), deletion manifest, account type catalog, and credit note infrastructure details.