decisions
Payment Charge-Gating & Resilience Plan
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 Charge-Gating & Resilience Plan
Status: Phases 0–4 implemented (2026-07-21, build-verified; needs runtime QA on a test user). Phase 5 partial — see §5. Owner: Farhan
Implementation note (2026-07-21). Phases 0–4 are code-complete for the customer Initial (
processPayment) path and build green; Airtable is removed from the whole payment/bundle flow (Initial, Addition, Reconnect, admin add-to-one-sub, passport provisioning). The executor auto-charge path is deleted, so every run now parks inAWAITING_ADMINand onlytriggerPaymentForRunmoves money. Not yet runtime-tested end-to-end (build + unit specs only). Phase 5: CPX already has a 30s HTTP timeout; charge-idempotency key and AsyncLocalStorage userId log-stamping are deferred as isolated, runtime-tested follow-ups (both touch live money code / the global logging pipeline and should not ship unverified).
Builds on and partially supersedes docs/bundle-run-redesign.md.
That doc designed the BundleRun model as a parallel source of truth with charges
auto-firing on fulfillment and Airtable left untouched. This plan makes the BundleRun
the primary, durable object, gates all charging behind an explicit admin trigger,
and removes Airtable from the payment flow. It exists because a real prod payment was
silently lost (see §1).
Supersedes in
bundle-run-redesign.md:
- Decision #3 (
autoChargeOnFulfillmentdefault = ON) → admin-trigger-only (hard): the auto-charge-on-fulfillment path is removed. Charging happens only viatriggerPaymentForRun.- Decision #9 ("Airtable left exactly as-is") → Airtable is removed from the payment/bundle-run flow (utilities/feedback/OTP Airtable untouched — separate migration).
1. Why — the incident
On 2026-07-20 20:40 (prod), user Tolu Adetayo called processPayment. The synchronous
balance check passed and the API returned 200 with "Payment processing started…" —
but no charge, no BundleRun, no email, and nothing in admin. Confirmed from prod
logs + DB (no backgroundjobs / bundleruns for that date). See memory
processpayment-silent-background-failure.
Root cause is structural, in
payment-orchestration.service.ts:
- Gap A — the BundleRun is created last, in the fragile spot.
createRunForBundulruns at ~L867, after ~24 Airtable calls + Passport/CPX provisioning, all inside a fire-and-forgetvoid (async () => {})()block (begins ~L367). It "swallows its own errors and returns null," so any early hang/throw = no run, silently. Tolu's block died at the first Airtableawait. - Gap B — Airtable is on the critical path. ~24 awaited
updateProcessingRecordaudit writes gate run creation; each is a hang point. Pure telemetry — nothing reads them back. - Gap C — no admin notification.
AWAITING_ADMINis set in 3 places (bundle-run.service.ts:530,bundle-run-executor.service.ts:313/316); none notify. - Plus: the
200is returned before anything durable exists; the per-runchargePolicy.autoChargeOnFulfillmentschema default (true) contradicts the globalBundleRunSettingdefault (false).
2. What already exists (do NOT rebuild)
| Capability | Where |
|---|---|
BundleRun + BundleRunItem state machine, AWAITING_ADMIN status |
src/bundle-run/schemas/*, enums/bundle-run.enums.ts |
Per-run + global charge-hold flag (autoChargeOnFulfillment, global default already false) |
bundle-run-setting.schema.ts, bundle-run.service.ts:157-163 |
Admin "charge now" = triggerPaymentForRun(runId, itemIds?) (also retry; per-user lock; skips CREATED; re-derives Initial/Addition from live state) |
bundle-run-admin.resolver.ts:34, bundle-run-admin.service.ts:125-188, bundle-run-payment.service.ts:66-174 |
| Mint-on-reveal VC at admin card reveal | bundle-run-admin.service.ts:673-748 (_revealCardForSub) |
| Admin actions: claim, mark-handled, reveal creds/card, convert-to-manual, mark-manual-outcome, retry, rerun, per-run toggle | bundle-run-admin.service.ts |
| Event-driven executor (concurrency 3, never throws) + 10-min reconcile cron for stuck runs | bundle-run-executor.service.ts, bundle-run-reconcile.job.ts |
Admin alert path (email → OPS_ALERT_EMAILS) |
NotificationsService.sendAdminAlert, notifications.service.ts:252 |
| Durable job model + status + reconcile pattern | payment/schemas/background-job.schema.ts |
The target flow is ~80% present. This plan is wiring + hardening + removals, not a rebuild.
3. Locked decisions (this effort)
- Charge gating = admin-trigger-only (hard). Remove/disable the executor's
auto-charge-on-fulfillment branch (
bundle-run-executor.service.ts:206-208). Charging is possible only throughtriggerPaymentForRun. Align the schema default tofalseand drop thetruedefault to kill the mismatch. Manual items already never auto-charge — this makes automated items match. - Durability = reuse Mongo
BackgroundJob+ reconcile cron. No Redis/queue. Persist the job before returning200; a worker resumes it; the cron is the backstop. - Airtable removal = payment/bundle-run flow only. Strip it from
payment-orchestration,payment-admin, and the passport charge path. Leave utilities/feedback/OTP Airtable for a later, separate migration.
4. Target end-state flow
customer processPayment / admin addToOneSub
→ [sync] validate user, subs, account ownership, balance (read-only, no money)
→ [sync] persist a durable BackgroundJob (PROCESS_BUNDUL) ← durability point
→ return 200 "we're on it"
─────────────────────────────────────────────────────────
→ [worker, resumable] create BundleRun FIRST (status collecting) ← never silent
→ [worker] provision: Passport account, CPX link, ACH stamp
(each tracked as run/item state + history; failure → AWAITING_ADMIN + reason)
→ [worker] executor swaps cards (automated) / awaits admin (manual)
→ run lands in AWAITING_ADMIN → sendAdminAlert → admin sees it in dashboard
─────────────────────────────────────────────────────────
ADMIN: reviews, reveals creds/cards, edits VCs, fixes items
ADMIN clicks "trigger payment" → triggerPaymentForRun → MONEY MOVES (only here)
→ run finalizes completed / completed_with_failures
No charge occurs anywhere before the admin trigger.
5. Phases (each independently shippable)
Phase 0 — Stop silent loss (ship first).
- Persist a
BackgroundJobsynchronously before the200; replace the fire-and-forgetvoid (async)()with a worker that resumes from that job. On process death the reconcile cron re-picks it up. - Reconciliation alert: any bundul attempt with a persisted job but no BundleRun
within N minutes →
sendAdminAlert. Permanent backstop.
Phase 1 — Create the run first, durably.
- Reorder so
createRunForBundulis the first durable action after the sync checks, before provisioning. - Make it non-swallowing on the critical path: if the run can't be created, fail
loudly + alert; never proceed silently / return
null. - Move Passport/CPX/ACH provisioning to run/item state + history entries. A
provisioning failure parks the run in
AWAITING_ADMINwith a clear reason.
Phase 2 — Remove Airtable from the flow (see §6 for the call-site inventory).
Phase 3 — Delete the auto-charge path.
- Remove the
if (autoChargeOnFulfillment) chargeItem()branch in the executor; align schema default tofalse. Every run now lands inAWAITING_ADMIN. KeeptriggerPaymentForRunas the sole charge entry point.
Phase 4 — Notify admin on AWAITING_ADMIN.
- Emit an event on run creation /
AWAITING_ADMIN; a listener callsNotificationsService.sendAdminAlert(user, services, amount, reason). Register a newNOTIFICATION_AUTOMATIONSkeybundle_run_awaitingso it shows in the admin automations overview. Optionally add push/Google-Chat to match claim/mark-handled.
Phase 5 — Resilience hardening.
- Item-level idempotency key on the Passport charge (atop the existing per-user lock +
skip-
CREATED) so retries can't double-charge — protects the single-fire Bundul fee. - Hard timeouts on every remaining external call in the flow (CPX especially; Passport already has one).
- userId log-stamping (AsyncLocalStorage + pino mixin) so every run/charge line is filterable by user in BetterStack.
- Extend the reconcile cron to also catch runs stuck in
collecting/provisioning, not justswapping.
6. Airtable removal inventory (payment/bundle flow)
Delete these call sites (audit telemetry; replaced by BundleRun status + item history +
structured logs). getBundulPricing stays — it already reads the DB first and only falls
back to Airtable when unseeded.
payment-orchestration.service.ts:getAirtableRecordIDOnCustomersTableL373;createProcessingRecordL386, L497;updateProcessingRecordL455, L476, L590, L632, L648, L662, L700, L721, L853, L890, L947, L959, L974, L994, L1017, L1025 (+ the addition path L1101, L1108, L1169, L1247, L1263, L1303 and reconnect L1566, L1588).payment-admin.service.ts:createProcessingRecordL173.payment.resolver.ts:createProcessingRecordL266, L411.passport/services/user-passport.service.ts:updateProcessingRecordL284, L296, L308, L320.
Out of scope (leave as-is): utility-invoice Airtable (email-parser,
user-subscription, subscription-write), feedback.service.ts, the airtable/triggers/*
OTP subsystem, and the admin passthrough resolver. The inbound Airtable "Burger" trigger
controller (airtable/triggers/controllers/airtable-trigger.controller.ts) starts payments
from Airtable — confirm it's unused for new work before retiring that entry point.
7. Sequencing, risk, verification
- Low-risk / independently shippable: Phase 0, 2, 4.
- Behavioral change: Phase 1 + 3 (charge gating). The admin board already supports the target model, so the surface change is small; the risk is ordering/provisioning-as-state.
- No new infrastructure (decision #2).
- Verification per phase: unit + a scripted end-to-end on a test user
(betatesting@bundul.io) — trigger
processPayment, assert (a) a durable job exists immediately, (b) a BundleRun inAWAITING_ADMINappears, (c) no Passport charge untiltriggerPaymentForRun, (d) the admin alert fires. Re-run the BetterStack funnel query (processPayment calledvsAWAITING_ADMINvs charge) to confirm no silent drops. - Highest-risk number remains the Bundul fee — must fire exactly once per One Sub, guarded by the per-user lock + live-state Initial/Addition routing + the new idempotency key (Phase 5).
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.