Bundul
Internal
Browse docs
Waiting for review

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/ and docs/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 (autoChargeOnFulfillment default = ON) → now admin-trigger-only (hard); the auto-charge-on-fulfillment path is being removed. Charging happens only via triggerPaymentForRun.
  • 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.

  1. 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).
  2. Customer side — the Bundul charge (Movement 2). Bundul charges the customer's linked bank (Plaid → Passport ACH). This is entirely createRecurringSubscription and 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 a connectionStatus (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

  1. 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.
  2. Engine is TBD. Orchestration goes through a ServiceAutomationProvider seam. browser-use is the only real impl; puppeteer is a placeholder that throws "not implemented"; manual routes to admin.
  3. autoChargeOnFulfillment toggle (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.
  4. 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 hasExistingPassportSubscriptions at charge time.

  5. 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).
  6. Charge the successful subset; surface failures with a clear reason + retry.
  7. Admin auth reuses existing webhookUsername / webhookPassword (BasicAuthGuard creds). No new identity system.
  8. Manual card swap = admin acts with the customer's creds. Dashboard exposes an access-controlled, audit-logged reveal of decrypted creds + VC number.
  9. Airtable is left exactly as-is. No new Airtable writes, no removal of existing ones. The new BundleRun is a parallel Mongo source of truth for the dashboard.
  10. 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 — swapCard cannot prompt OTP itself.
  • The run does not block on an awaiting_customer item; 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 via subscriptionsVersion bump + 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 + BundleRunItem Mongo schemas.
  • ServiceAutomationProvider interface + registry (browser-use, puppeteer placeholder, manual).
  • Gate AI_LOGIN_REQUESTED on requiresManualAutomation === false.
  • processPayment background 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 updating BundleRunItem; "all done" computed by querying item state (restart-safe). Add a reconcile cron (clone cleanupStaleLogins).
  • Extract createPaymentForItems(runId, itemIds) from the Airtable trigger controller, wrapping handleManualCreationOfSubscreateRecurringSubscription, 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 shared createPaymentForItems.

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. createSubWithoutDoubleCharging sets recurringSubscriptionCompleted = true and notifies, but does not appear to call createRecurringSubscription or 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.