decisions
Bundle Run Redesign — Payments, Automation & Bundul-ing
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/.
Bundle Run Redesign — Payments, Automation & Bundul-ing
Status: Largely implemented (Phases 0–4 substantially built as of 2026-07-21) Owner: Farhan
Update (2026-07-21): Two decisions below are superseded by
docs/payment-charge-gating-plan.md:
- Decision #3 (
autoChargeOnFulfillmentdefault = ON) → now admin-trigger-only (hard); the auto-charge-on-fulfillment path is being removed. Charging happens only viatriggerPaymentForRun.- Decision #9 ("Airtable left exactly as-is") → Airtable is being removed from the payment/bundle-run flow (utilities/feedback/OTP untouched). That plan also adds durability + admin-notification work not covered here, prompted by a silently-lost prod payment.
This document is the source of truth for the new bundling/payment/automation flow. It supersedes the ad-hoc Airtable BurgerTrigger orchestration for new work — the existing Airtable flow is left running untouched during the transition.
1. Core concepts
A bundle involves two completely separate money movements. Never conflate them.
- Merchant side — the card swap (Movement 1). Repoints the merchant (Netflix,
ConEd, etc.) at Bundul's virtual card (VC) or ACH so the merchant now bills
Bundul. No customer money moves. Done by
CardSwapService(automated) or by an admin manually (manual services). - Customer side — the Bundul charge (Movement 2). Bundul charges the customer's
linked bank (Plaid → Passport ACH). This is entirely
createRecurringSubscriptionand is the only customer-money step. It sets up:- One-time top-up — for services in the bundle not yet paid this month.
- Recurring schedule — monthly charge for the sum of service prices.
- Bundul fee — its own recurring schedule. Initial only.
The two lifecycle phases
- createSub (per service): collect + encrypt creds. Automated services
(
requiresManualAutomation === false) additionally attempt login (AI login / HITL) and end at aconnectionStatus(e.g.connected). Manual services (requiresManualAutomation === true) do not attempt login — creds stored only. - Bundul (the run): balance check → Passport/CPX setup → create VCs for all
subs → create
BundleRun→ route each item → card swap (automated) / await admin (manual) → charge per the toggle.
2. Locked decisions
- Routing signal =
requiresManualAutomation. It gates both login (createSub) and card swap (bundul).- automated + valid session → run card swap.
- automated + no/expired session → immediate
swap_failed, reason "reconnect required" (a first-class failure, not a fallback to manual). - manual →
awaiting_manual(admin), never enters card swap.
- Engine is TBD. Orchestration goes through a
ServiceAutomationProviderseam.browser-useis the only real impl;puppeteeris a placeholder that throws "not implemented";manualroutes to admin. autoChargeOnFulfillmenttoggle (per run, global default, admin-overridable). The single switch for all run shapes (automated / manual / mixed):- ON → charge each item the moment it's fulfilled (
swap_succeeded/manual_done), as it settles. - OFF → charge nothing until an admin hits "trigger all" (batches the fulfilled set).
- Default: ON.
- ON → charge each item the moment it's fulfilled (
- Initial vs Addition is derived from live DB state, NOT from the toggle or a
run-local flag. Rule:
First charge in the user's lifecycle = Initial (creates the Bundul fee). Every charge after — same run, retry, future month, manual top-up — = Addition (bumps the existing recurring amount, no new fee). Computed from
hasExistingPassportSubscriptionsat charge time. - Per-user payment serialization lock wraps
createPaymentForItems. Required because the toggle lets charges fire at different times / concurrently; without the lock two charges could both read "no existing sub" → two Initials → duplicate Bundul fee + two recurring schedules. The idempotency key does NOT protect against this (different subId sets → different keys). - Charge the successful subset; surface failures with a clear reason + retry.
- Admin auth reuses existing
webhookUsername/webhookPassword(BasicAuthGuard creds). No new identity system. - Manual card swap = admin acts with the customer's creds. Dashboard exposes an access-controlled, audit-logged reveal of decrypted creds + VC number.
- Airtable is left exactly as-is. No new Airtable writes, no removal of existing
ones. The new
BundleRunis a parallel Mongo source of truth for the dashboard. - Split payment is out of scope for this redesign (reserved, not built).
Retry semantics (state-aware)
| Failure state | Retry action | Driver |
|---|---|---|
swap_failed (transient/decline) |
re-run swap only | admin or customer |
swap_failed "reconnect required" |
re-login → swap | customer |
awaiting_customer (mid-swap re-auth) |
customer re-auths → auto-retry swap | customer |
manual_failed |
admin re-attempts manual swap | admin |
creds wrong / invalid_credentials |
re-collect creds → re-login / re-store | customer |
- In-run retries (minutes later) re-execute the item in place.
- Later customer "fix failed services" taps spin a new Addition run.
- Re-auth (
awaiting_customer) routes through the login flow (which has the OTP sub-flow), not the swap session —swapCardcannot prompt OTP itself. - The run does not block on an
awaiting_customeritem; other items proceed and (toggle ON) charge; the item resumes whenever the customer acts (→ Addition).
3. Data model
BundleRun {
userId
trigger: 'customer_bundul' | 'customer_retry' | 'admin'
chargePolicy: { autoChargeOnFulfillment: boolean } // default from global setting
status: 'collecting' | 'swapping' | 'awaiting_admin'
| 'completed' | 'completed_with_failures' | 'failed'
accountIdToPayWith
paymentProcessId // correlate w/ Airtable Processing record
createdAt / updatedAt
// aggregate status DERIVED from items — not stored, to avoid drift
}
BundleRunItem {
runId
userSubscriptionId
serviceName // denormalized
fulfillmentMode: 'automated' | 'manual'
engine: 'browser-use' | 'puppeteer' | 'manual'
status: 'pending' | 'swapping' | 'swap_succeeded' | 'swap_failed'
| 'awaiting_customer' | 'awaiting_manual' | 'manual_done' | 'manual_failed'
failureReason // internal
customerFacingReason // clean message for app
paymentStatus: 'none' | 'charging' | 'created' | 'charge_failed'
chargeType: 'initial' | 'addition' | null // audit only; does NOT drive routing
amountSnapshot
vcStableId
liveUrl / screenshotUrl
attempts / lastAttemptAt
history: [{ status, at, note }] // per-transition audit trail
createdAt / updatedAt
}
CredentialRevealAudit {
adminUsername, runItemId, userSubscriptionId,
fieldsRevealed: string[], revealedAt, ip?
}
4. Scenario reference
All assume autoChargeOnFulfillment = ON unless noted.
- All automated, all pass → swaps succeed → subset (= all) charged Initial
(top-up + recurring + fee). Run
completed. No admin. - All automated, partial → {A,B} succeed → charged Initial. {C} swap_failed,
{D} no session → swap_failed "reconnect required". Run
completed_with_failures. Retry C (swap only) / D (re-login → swap); on success charged Addition. - Mixed → automated subset charges Initial as it lands; admin works manual items, which charge Addition as each is marked done. (Toggle OFF → all held for one admin "trigger all" → single Initial batch.)
- All manual → all
awaiting_manual, VCs created. Admin reveals creds+VC (audited), swaps manually, marks done; first done item charges Initial, rest Addition (toggle ON) or one batch (OFF). - Re-auth mid-swap → item
awaiting_customer; customer notified viasubscriptionsVersionbump + push; re-login (OTP-capable) → auto-retry swap → charge.
5. Implementation phases
Phase 0 — Admin guard (small). Guard admin GraphQL resolvers with the existing
webhookUsername/webhookPassword creds. Retrofit onto the unguarded
adminRefreshUtilityInvoices while here.
Phase 1 — Run model + engine seam (parallel to Airtable; no existing writes touched).
BundleRun+BundleRunItemMongo schemas.ServiceAutomationProviderinterface + registry (browser-use,puppeteerplaceholder,manual).- Gate
AI_LOGIN_REQUESTEDonrequiresManualAutomation === false. processPaymentbackground creates the run alongside existing Airtable writes.
Phase 2 — Parallel resumable swaps + payment triggers (reliability keystone).
- Replace the in-memory promise
Map/ sequential loop with per-item execution updatingBundleRunItem; "all done" computed by querying item state (restart-safe). Add a reconcile cron (clonecleanupStaleLogins). - Extract
createPaymentForItems(runId, itemIds)from the Airtable trigger controller, wrappinghandleManualCreationOfSubs→createRecurringSubscription, with Initial/Addition derived from live state. - Per-user payment serialization lock around
createPaymentForItems. - Wire
autoChargeOnFulfillment. The existing Airtable BurgerTrigger webhook keeps working in parallel and can also call the sharedcreatePaymentForItems.
Phase 3 — Read-only admin dashboard over the Mongo run (per-service status, live URLs, screenshots, errors; automated and manual side by side).
Phase 4 — Admin actions. markManualItem, audited revealServiceCredentials,
setItemCredsStatus, state-aware retryItems, triggerPaymentForRun, per-run toggle.
Later (explicitly deferred). Retire Burger/BurgerTrigger + the dead
automationServiceUrl/Puppeteer path; stop writing plaintext creds to Airtable; pick
the automation engine.
6. Open items / things to verify before relying on them
- Split-payment manual path is possibly stubbed.
createSubWithoutDoubleChargingsetsrecurringSubscriptionCompleted = trueand notifies, but does not appear to callcreateRecurringSubscriptionor any Passport charge. Out of scope here, but confirm before any split work. - The Bundul fee is the single highest-risk number: must fire exactly once per One Sub, guarded by the per-user lock + live-state Initial/Addition routing.
This is a record of a decision at the time. It is not edited — write a new record that supersedes it.