decisions
Payment Resilience Hardening
A frozen record of a decision at the time. Superseded by a new record rather than edited.
Written by the build · 2 September 2026
Frozen. A record of a decision at a point in time, not living documentation. Do not update it — supersede it with a new record instead. For how this works today, see
docs/explanation/anddocs/generated/.
Payment Resilience Hardening
Audit + fixes done 2026-07 (branch payment-resilience; admin UI on bundul-admin branch
financials-spent-vs-collected). Each item is Issue → Problem → Fix. The deep audit
and rationale live in payment-resilience-research.md;
this doc is the short "what was wrong and what shipped."
The through-line: the collect side (getting paid by the customer) and the disburse side (paying their providers) were decoupled — nothing stopped Bundul paying out for a customer who stopped paying. Most of this work reconnects them and makes failures visible.
Backend — collection failures & dunning
1. A failed monthly collection was silently dropped
- Issue: When a customer's monthly ACH collection FAILED, the webhook handler only logged and returned.
- Problem: No retry, no notification, no suspension — and VC top-ups / utility payments kept running, so Bundul kept paying providers with money it never collected. Indefinitely.
- Fix: A delinquency state machine on
UserPassportSubscription(paymentStatus,collectionFailureCount,disbursementPaused): record the failure, markpast_due, schedule a backed-off retry, notify the customer + ops, and gate disbursement (the VC top-up loop skips paused users). —src/payment/orchestration/payment-retry.service.ts,src/webhook/webhook.service.ts,src/subscriptions/orchestration/virtual-card-orchestration.service.ts
2. Only one charge type could retry; no backoff, no error classification
- Issue: Retry logic existed only for "addition one-time" charges; recurring collections had none. Retries had no backoff and didn't look at why a charge failed.
- Problem: A failed month-3 collection never retried; and a permanent failure (closed account) was treated like a transient blip.
- Fix:
RECURRING_CHARGE_RETRYbackground job with a +1d / +3d / +7d backoff (runAfteron the job), plus transient-vs-hard classification (NSF/closed/R0x → hard). Re-issued as an idempotent one-time catch-up charge. —src/payment/orchestration/payment-retry.service.ts,src/payment/schemas/background-job.schema.ts
3. Terminal state sent the wrong email
- Issue: On the final failed attempt (retries exhausted), the code still sent the "we'll retry" email.
- Problem: Customer told we'd retry when we wouldn't; no clear "your services are paused" message.
- Fix: When no retry remains, send the terminal "services paused, reactivate anytime" email instead. —
src/payment/orchestration/payment-retry.service.ts - Superseded 2026-07-10: the original decision here was pause, never cancel (lodge cards keep their number so reactivation just resumes top-ups; cancelling forces card re-issuance + re-registration per provider). That tradeoff no longer holds given the CPX top-up bug ([[vc-topup-newcard-bug]]) — see the 6-month-prefund workaround for live VC customers.
4. Reactivation waited for the next monthly cycle
- Issue: A paused customer only recovered when their next monthly charge happened to succeed — potentially weeks away.
- Problem: Customer fixes their payment method but services stay off for days.
- Fix: When a customer (re)links their bank, emit
payment.method_updated→ immediate catch-up charge for past-due users → on success, un-pause. Decoupled via event + a single listener (no module cycle). —src/passport/services/user-passport.service.ts,src/payment/orchestration/payment-reactivation.listener.ts
5. One-time / catch-up charges could double-charge
- Issue:
createOneTimeChargeForUserwas called withallowDuplicate: 'true', and retries carried no idempotency token. - Problem: A duplicated retry job or a re-delivered FAILED webhook could charge the customer twice.
- Fix: Optional
externalIdon the charge (Passport dedups;allowDuplicateoff when present). Retries pass a per-attempt stable key; duplicate FAILED webhooks are deduped bytransactionId. —src/passport/services/user-passport.service.ts,src/payment/orchestration/payment-retry.service.ts
Backend — external API resilience
6. No HTTP timeout on any Passport/CPX call
- Issue: Every outbound Passport (ACH) and CPX (virtual card) call had no timeout (axios default = infinite).
- Problem: One hung upstream request blocks forever; in the serial per-user top-up cron, a single hang stalls every remaining user.
- Fix: Hard timeout (
PAYMENT_HTTP_TIMEOUT_MS, default 30s) at the module level and in the Passport request funnel. —src/passport/passport.module.ts,src/virtual-card/virtual-card.module.ts,src/passport/passport.service.ts,src/common/configuration.ts
7. No retry on transient API failures
- Issue: Money-movement calls were fire-once; a transient 5xx / 429 / network blip failed the charge outright.
- Problem: Recoverable failures became hard failures (and, mid-creation, triggered rollback).
- Fix: The Passport request funnel retries on 429/5xx (always) and on network errors for GET only — never blindly re-POSTing a charge that may have already applied. Exponential backoff. —
src/passport/passport.service.ts
Backend — virtual cards
8. VC top-up failures were only caught 2 days late
- Issue: The only detector was a daily reconcile that flags unfunded top-ups at T+2. For customers billed on the 1st–3rd, the alert fired after the card already declined.
- Problem: No ahead-of-time signal; also missed under-funding and initial-funding failures.
- Fix: A daily pre-charge sweep (D−2 lead): for cards due within the window, flag not-funded (red) / under-funded (amber) and auto-top-up the at-risk cards (idempotency-guarded), before the charge. —
src/subscriptions/orchestration/virtual-card-orchestration.service.ts,src/jobs/jobs/vc-precharge-alert.job.ts
9. Missed CPX webhooks left permanent gaps
- Issue: VC charges/refunds were recorded only from CPX webhooks; a missed webhook meant a silently wrong per-card balance/ledger.
- Problem: The webhook-only pipeline can't detect its own gaps.
- Fix: A daily balance-drift reconcile — fetch each active card's live CPX balance; if it diverges from the cached value with no matching ledger activity, alert (likely missed webhook) and refresh the cache. —
src/subscriptions/orchestration/virtual-card-orchestration.service.ts,src/jobs/jobs/virtual-card-reconcile.job.ts
10. A pre-funded card could be charged for an uncollected month
- Issue: If a card was topped up before the customer's collection failed, a provider could still charge it — the gate can't un-fund a loaded card.
- Problem: Bundul fronts that cycle's spend for a non-payer (recovered later via dunning + True-Up, but still exposure).
- Fix: Opt-in strict "collect-first" policy —
VC_TOPUP_SKIP_PAST_DUE=truealso skips any past-due user's top-up (default off keeps "first miss is fronted"). The gate always skips fully-paused users. —src/subscriptions/orchestration/virtual-card-orchestration.service.ts
Backend — reconciliation & stuck states
11. Records could get stuck forever
- Issue: Payment records stuck
PENDING(no terminal webhook), orphanedPROCESSINGretry jobs, and True-up records stuckapproved-but-never-executed had no sweeper. - Problem: They sit silently — money state never resolves and nobody's told.
- Fix: A 4-hourly stuck-state sweep: alert on stale
PENDINGrecords (>3d), reset orphaned (idempotent) retry jobs, and alert onapprovedtrue-ups (>1h). —src/payment/orchestration/payment-retry.service.ts,src/trueup/services/trueup.service.ts
12. The hourly ledger sync could wedge or silently drop entries
- Issue: An in-memory "in progress" flag could wedge all future syncs; per-user errors and page-cap truncation were swallowed.
- Problem: Reconciliation could quietly stop or miss entries with no signal.
- Fix: Watchdog forces a fresh run if the flag wedges >2h; ops alert on error spikes and page-cap truncation. —
src/ledger/services/ledger-sync.service.ts
Backend — fee changes
13. A fee change couldn't reach existing customers, and the two fee carriers couldn't move together
- Issue: Changing the global Bundul fee only affected new signups. The fee is carried in two places (embedded in the main charge + a separate BOOK sweep) and the BOOK-sweep amount had no update method at all.
- Problem: No way to re-price existing customers; any attempt would move one carrier and not the other → corrupts True-Up.
- Fix: Admin toggle
applyTo: 'new_only' | 'all_existing'. Built the missing BOOK-sweep updater;changeFeeForUserupdates both carriers + Mongo atomically (idempotent, audited); an event-driven, resumable migration re-prices all existing customers with an ops summary. —src/pricing/pricing.service.ts,src/payment/records/user-payment.service.ts,src/passport/services/user-passport.service.ts,src/payment/records/fee-change.listener.ts
Notifications & customer emails
14. New ops alerts pointed at a Slack channel that doesn't exist
- Issue: The new resilience alerts were written to a
#paymentsSlack channel. - Problem:
#paymentsdoesn't exist; alerts would go nowhere. - Fix: All ops alerts route through
NotificationsService.sendAdminAlert→ email toOPS_ALERT_EMAILSand recorded toNotificationLogso they surface on the admin dashboard. 9 new automations registered in the catalog. —src/notifications/*, and the alerting call sites in the services above.
15. Dunning customer emails had no templates
- Issue: The dunning flow referenced Infobip templates that didn't exist.
- Problem: Customer-facing failure/paused/recovered emails wouldn't send.
- Fix: Created 4 Infobip templates in the existing house style (cloned from a live template via
POST /email/1/templates;{$placeholder}syntax): payment failed (soft/hard), services paused, payment recovered. Wired intoEmailTemplates, sent viasendEmailWithTemplate. —src/infobip/infobip.service.ts,scripts/create-dunning-templates.cjs
Admin dashboard (bundul-admin)
16. No always-on view of spent vs collected per customer
- Issue: Collected-vs-spent only existed inside the quarterly True-Up.
- Problem: Ops couldn't see, at a glance, which customers Bundul is exposed on right now.
- Fix: Backend
GET /admin/ledger/financials[/:userId](collected / fees / spent / delta + dunning status, sorted by exposure). Admin Financials page + per-customer detail with the month breakdown and dunning card. Live screenshot-verified. —src/ledger/controllers/ledger-admin.controller.ts; bundul-adminFinancialsPage.tsx/FinancialsDetailPage.tsx.
Deferred (dedicated, tested PRs)
17. Full Mongo multi-document transactions
- Issue: Several flows do multiple DB writes (webhook handlers, the Addition Passport→Mongo sequence) with no atomicity, so a mid-way crash leaves partial state.
- Why deferred: Needs replica-set verification +
sessionthreading across many money-write sites; risky to batch. Contained mitigations shipped instead (real-time drift alert on the Addition seam; the ledger sync + stuck-state sweepers catch most partial states after the fact).
18. Distributed cron lock (multi-instance safety)
- Issue:
@nestjs/schedulefires on every ECS task; with >1 task (or during a rolling deploy's task overlap) money crons can double-fire. No distributed lock. - Why deferred: Per decision to skip the ECS item for now. Fix would be a Mongo advisory-lock doc (unique key + TTL) around each money cron + a unique index to make the top-up guard atomic.
desiredCountis a service-level setting (not in the repo).
19. Webhook authentication
- Issue:
/webhook/passportand/webhook(Plaid) are unauthenticated and forgeable (CPXcard-eventis guarded). - Why deferred: Parked per decision; revisit when security work is scheduled.
Config to set for production
| Env var | Purpose | Default |
|---|---|---|
OPS_ALERT_EMAILS |
Recipients for all ops alerts | olamide@ , farhan.s@ |
PAYMENT_UPDATE_URL |
"Update payment" link in dunning emails | app.bundul.io/account/payment |
PAYMENT_HTTP_TIMEOUT_MS |
Passport/CPX call timeout | 30000 |
PAYMENT_HTTP_MAX_ATTEMPTS |
Passport retry attempts | 3 |
VC_TOPUP_SKIP_PAST_DUE |
Strict collect-first (skip past-due top-ups) | off |
VC_PRECHARGE_WINDOW_DAYS |
Pre-charge alert lead time | 2 |
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.