Bundul
Internal
Browse docs
Waiting for review

explanation

Bundul Financial System — Technical Reference

Written by the build · 2 September 2026 · owner @farhan-s · reviewed 2026-09-01

Bundul Financial System — Technical Reference

Audience: Engineers and operators working on the Bundul backend. Purpose: Complete reference for how money moves through the platform — every system, every event, every ledger write, every quirk. Last updated: July 2026 (v4 — One Sub due-date suggestion (§3.7); VC lifecycle: 6-month upfront funding, broken CPX top-ups, cancel+recreate under fresh SID)


Table of Contents

  1. Overview
  2. External Systems
  3. Subscriptions
  4. Payment Method Routing — VC vs ACH
  5. Virtual Cards — Full Lifecycle
  6. ACH Service Payments
  7. Webhooks
  8. Ledger System
  9. True-Up (Quarterly Reconciliation)
  10. Admin — True-Up Charge Execution 10a. Things that shipped after this document was first written
  11. Data Model Reference
  12. Key Quirks & Edge Cases

1. Overview

Bundul is a subscription bundling platform. Its financial job is simple in concept, complex in execution:

  1. Collect a monthly fee from the user via ACH (through Passport)
  2. Pay the user's service providers on their behalf — either by funding a virtual card the provider charges (CPX), or by registering ACH payment credentials directly with the provider (Passport)
  3. Reconcile quarterly whether the user actually paid exactly what was spent on their services (True-Up)

Money Flow (High Level)

User's External Bank Account
        │
        │  ACH COLLECT (Passport recurring charge)
        ▼
Bundul Passport Account       ← receives user's monthly subscription payment
        │
        ├──── VC path ────────► CPX Lodge Card ──────► Service Provider
        │      (PIF funds card)  (provider charges card)
        │
        └──── ACH path ───────► Service Provider
               (provider debits user's Passport-linked account directly)

The Two Payment Rails

Rail System When Used How Provider Gets Paid
Virtual Card CPX Service supports card-on-file billing Bundul funds a lodge card; provider auto-charges it
ACH Passport Service supports ACH bank debit Provider debits user's Passport-linked external account

The choice of rail is set at the service definition level (BundulSupportedSubscription.paymentMethod) and stamped onto every user's subscription at bundling time. A user can have both VC and ACH subscriptions simultaneously.


2. External Systems

2.1 Passport

What it is: An ACH execution engine. Bundul uses it as the backbone for all money movement between user bank accounts and Bundul.

What it holds:

  • A Passport account per user (passportAccountId) — a virtual wallet that receives incoming ACH debits from the user's external bank
  • The user's external bank account (externalAccountDetails) — the actual checking/savings account linked via Plaid
  • A Bundul-owned true-up account (BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID) — used exclusively for true-up deposits and withdrawals

What it executes:

  • ACH COLLECT — pulls money from user's external bank into their Passport account (subscription charges)
  • ACH SEND — pushes money from one Passport account to another (true-up deposits/withdrawals)
  • BOOK transfer — internal Bundul ledger transfer (Bundul fee accounting)

What it emits: Webhooks on every transaction state change (SCHEDULED → COMPLETED | FAILED).

Key credentials stored per user:

User.passportAccountDetails {
  customerPassportId         // Bundul's customer ID in Passport
  passportAccountId          // User's Passport wallet ID
  passportAccountNumber      // Encrypted bank account number (used for VC PIF funding)
  passportAccountRoutingNumber // Encrypted routing number
  externalAccountDetails[]   // Plaid-linked external bank accounts
  cpxBankAccountId           // Passport account registered inside CPX as funding source
}

2.2 CPX

What it is: A virtual card issuer and payment processing network. Bundul submits Payment Instruction Files (PIFs) to CPX to fund virtual lodge cards; providers charge those cards directly.

What it holds:

  • A Bundul buyer account — the top-level account under which all cards are issued
  • A supplier per user-subscription pair — represents the user+service relationship in CPX (sid = vcStableId)
  • Lodge cards — persistent virtual card numbers, topped up monthly

What it emits: Webhooks on every card lifecycle event (funded, charged, refunded, cancelled, etc.)

Why CPX over issuing cards directly: CPX handles card number generation, PCI compliance, merchant settlement, and provides a webhook-driven event stream for every transaction. Bundul never stores raw card numbers.


2.3 Plaid

What it is: A bank account aggregation service. Users connect their bank accounts via Plaid Link; Bundul uses the resulting access token to verify balances and sync transaction history.

What Bundul uses it for:

Use How
Balance verification confirmIfPlaidAccountHasEnoughForSub(accountId, amount) before allowing payment
Transaction sync SYNC_UPDATES_AVAILABLE webhook → /transactions/sync → stored in UserPlaidTransactions
Recurring transaction detection RECURRING_TRANSACTIONS_UPDATE webhook → /transactions/recurring/get → stored in UserPlaidRecurringTransactions
Re-authentication ITEM_LOGIN_REQUIRED error → mark token invalid → push + email user with hosted Plaid Link URL

Data stored per user:

PlaidToken {
  userId
  itemId            // Plaid's ID for the bank connection
  accessToken       // Used for all API calls
  lastSynced
  status            // 'active' | 'invalid'
  transactionCursor // Tracks sync position per itemId
}

Important: Plaid is read-only for payment decisions. It never initiates money movement. All actual ACH execution goes through Passport.


3. Subscriptions

3.1 Data Models

There are three distinct subscription models:

Model Purpose
BundulSupportedSubscription Service catalog — what Bundul offers. Defines service name, base amount, paymentMethod (VC or ACH), automation type. One document per service offering.
UserBundulSupportedSubscription Junction — which services a user has access to (not necessarily subscribed).
UserSubscription A user's live subscription to one service. Created at bundling. Holds the active state, payment method, VC/ACH details, and billing history.

3.2 Subscription Creation Flow

When a user bundles services:

1. For each selected service:
   a. Create UserSubscription
      - servicePaymentMethod copied from BundulSupportedSubscription.paymentMethod
      - amount from BundulSupportedSubscription.amount
      - status: 'active'

2. Schedule Passport recurring charge (subscription fee)
   - Type: ACH COLLECT
   - Source: user's external account → user's Passport account
   - externalId: "BUNDUL-SUB-{userId}"
   - Frequency: MONTHLY
   - dayOfMonth: extracted from startDate
   - Amount: sum of all service amounts

3. Schedule Passport recurring charge (Bundul fee) — SEPARATE charge
   - externalId: "BUNDUL-FEE-{userId}"
   - Amount: flat Bundul platform fee

4. For each VC-type subscription:
   → createVCForService() [see Section 5]

5. For each ACH-type subscription:
   → Automation registers ACH credentials with provider
   → stampAchPaymentDetails() on UserSubscription

3.3 Two Separate Charges Per User

Every active user has two distinct Passport recurring charges:

Charge externalId What It Covers
Subscription charge BUNDUL-SUB-{userId} Sum of all service subscription amounts
Bundul fee BUNDUL-FEE-{userId} Flat Bundul platform fee

These are on the same monthly schedule but are independent Passport recurring transactions. The webhook identifies which is which via the externalId prefix.

3.4 One-Time Charges

Triggered in the Addition flow (user adds a new service mid-cycle) or to catch up unpaid months.

createOneTimeChargeForUser()
  → Passport ACH COLLECT (external → Passport account)
  → externalId: "BUNDUL-ONE-TIME-{userId}"
  → UserLedger: entryType='one_time_charge', status='pending'
  → Settles via webhook when COMPLETED

3.5 Split Payment

When a user cannot pay the full amount upfront, the charge is split into two parts scheduled on different days. Each part is a separate Passport recurring charge with splitGroupId linking them in the ledger. Both parts must settle for the subscription to be considered fully paid for the month.

3.6 Charge Schedule

startDate (MM/DD/YYYY) → dayOfMonth extracted
Passport: frequency=MONTH, interval=1, startDate=...
Executes on that day every month until cancelled

3.7 One Sub Due Date — How We Suggest It

The One Sub due date is the day each month Bundul collects the customer's bundle payment (the BUNDUL-SUB-{userId} recurring charge). The customer doesn't type in a random day — during onboarding we suggest up to 5 good dates and they pick one.

The goal of a good date: collect just after the customer gets paid and before their bills pile up — so the money is in the account when we pull it, and Bundul isn't fronting cash for services that are about to charge. Every suggestion rule below serves that one goal.

Where it happens:

Customer opens "pick your One Sub date"
   → query getUserOneSubDueDateRange  → returns suggested dates
Customer picks one
   → mutation updateOneSubDueDateForUser  → saves user.oneSubDueDate
Customer bundles (processPayment)
   → the SAVED user.oneSubDueDate becomes the Passport recurring-charge startDate

Nothing is recomputed at bundle time — bundling just uses the date the customer already picked. All the logic lives in the suggestion step, which has two mechanisms:

Primary — "Smart Due Date" (AI, uses live Plaid recurring streams):

  1. Safe window (deterministic risk gate). We compute a window [today → windowEnd] the customer must choose within. windowEnd is the EARLIER of:
    • an income-stability limit — how far out we'll allow based on how steady their income looks: steady income → up to 30 days, moderate → 21 days, erratic → 7 days; and
    • a $50 exposure cap — walking forward day by day, we add up the bills predicted to hit by each day and stop the moment the total would exceed $50 (the amount Bundul is willing to front before collecting). If the window is short (≤14 days) because bills are due soon, we show the customer a plain-language reason (e.g. "Because Netflix ($15.99) and … are due soon, please pick a date on or before {windowEnd}").
  2. Pick dates inside the window (Claude). The AI proposes up to 5 dates within the window, favouring: just after a payday, in a quiet gap between big bills, at least 1–2 days clear of a large bill, and spread out (not 5 days in a row). Any date outside the window is hard-rejected.

Fallback — legacy heuristic (no AI, uses cached Plaid recurring data): used if the AI step returns nothing. It aims at the same "before the bills accumulate" target:

  • If every service is already paid this month → suggest the 1st of next month + the next 4 days.
  • Else if the bundle costs more than $75 (BUNDUL_THRESHOLD_FOR_ONESUB_FRONTING) → find the date by which the customer's own outflows add up to $75, and suggest the days just before it.
  • Else → suggest days around the most recent service's renewal date.
  • Always includes today as an option and pads the list to 5 dates.

At bundle time (turning the pick into the charge date):

  • No services paid yet this cycle → recurring startDate = user.oneSubDueDate (their pick).
  • Some/all already paid this cycle, or a free first month is granted → the recurring charge is deferred one month (getOneMonthFromDate), and any unpaid portion for the current cycle is collected now as a one-time charge.
  • Customer never picked a date → defaults to today.
  • The Bundul fee sweep is scheduled 5 business days after the recurring start, so the wallet is funded before the fee is pulled.

Key numbers: exposure cap $50; income-tier windows 30 / 21 / 7 days; restriction message shown when the window is ≤14 days; fronting threshold $75; fee-sweep lead 5 business days.


4. Payment Method Routing — VC vs ACH

4.1 How the Route Is Determined

BundulSupportedSubscription.paymentMethod  ('VC' | 'ACH')
         │
         │  stamped at bundling
         ▼
UserSubscription.servicePaymentMethod      ('VC' | 'ACH')
         │
         │  checked at execution time
         ▼
   VC path → requires vcStableId on UserSubscription
   ACH path → requires achPaymentDetails on UserSubscription

The paymentMethod on BundulSupportedSubscription is set once when the service is onboarded (comes from Airtable). It never changes. A user inherits the payment method of whatever service they bundle — they cannot choose.

4.2 vcStableId — The Canonical VC Identifier

vcStableId = "BundulInc-{userId}-{subscriptionId}"

This string serves two purposes:

  1. VirtualCard record identifier — indexed field on the VirtualCard collection
  2. UserSubscription stamp — written to UserSubscription.vcStableId after VC creation confirming the link

Note: vcStableId was previously used as supplier.sid for CPX webhook routing, but supplier.sid is always empty in CPX webhooks. CPX webhook routing now uses tid (see Section 7.2).

4.3 Routing Validation

Before any payment execution, the system validates:

  • If servicePaymentMethod === 'VC' → must have vcStableId → else error, trigger VC creation
  • If servicePaymentMethod === 'ACH' → must have achPaymentDetails → else error, trigger ACH setup

5. Virtual Cards — Full Lifecycle

5.1 Lodge Cards vs Disposable — The Decision

CPX supports two card types:

Type accountType Card number Lifespan Best for
Disposable DISP New number per charge Single use, expires after one charge One-time purchases
Lodge (Lodged) LDG Permanent, fixed Stays active until explicitly cancelled Recurring utility bills

Bundul uses lodge cards exclusively. The reason:

Utility providers (So Cal Gas, Edison, T-Mobile, etc.) store the card number on their system and auto-charge it each month. If Bundul issued a new disposable card every month, the provider's stored card would be invalid and the charge would fail. The lodge card maintains a stable, persistent card number that the provider can charge month after month — Bundul simply reloads the balance via a new PIF each cycle.

5.2 How a Lodge Card Is Funded via the Passport Account

This is the critical link between Passport and CPX:

User.passportAccountDetails.passportAccountNumber    (encrypted)
User.passportAccountDetails.passportAccountRoutingNumber (encrypted)
         │
         │  decrypted at PIF time
         ▼
PIF payload: bankAccountNumber + bankRoutingNumber
         │
         │  submitted to CPX
         ▼
CPX debits that bank account (Good Funds mode: next business day)
         │
         └► Lodge card loaded with funds

cpxBankAccountId (stored on passportAccountDetails) is the Passport account registered inside CPX as the authorized funding source. This registration happens once during user onboarding.

Good Funds mode vs DailyBill mode (historical):

  • DailyBill (old): CPX used Bundul's default billing account regardless of which user's credentials were in the PIF. VCs created under this mode show up in Bundul's Passport logs, not the user's.
  • Good Funds (current): CPX debits the account/routing number in each PIF the next business day. VCs created under Good Funds show up correctly against each user's Passport account.

This is why VCs created before the Good Funds migration don't appear in individual users' Passport transaction logs.

5.3 Card Creation — Step by Step

Triggered by VirtualCardOrchestrationService.createVCForService(subId, userId):

Step 1: _ensureSupplier()
  → Check if CPX supplier exists for vcStableId
  → If not: POST /supplier/v1/supplier { sid: vcStableId, ... }
  → Supplier represents this user+service in CPX

Step 2: Upsert VirtualCard record (DB)
  → Created before PIF submission intentionally
  → If PIF fails, we have a DB record to retry against
  → Fields: vcStableId, userId, subscriptionId, serviceName,
             cpxSupplierId, cpxSupplierSid (= vcStableId)

Step 3: _submitPif() — Payment Instruction File
  → POST /payment/v1/pif
  → Payload:
    {
      fileName: "singlepayment",
      records: [{
        transactionId:    <uuid — unique per call, NOT vcStableId>
        institutionId:    "Bundul"
        buyerId:          <Bundul CPX buyer ID>
        bankRoutingNumber: <decrypted from passportAccountDetails>
        bankAccountNumber: <decrypted from passportAccountDetails>
        amount:            price × VC_SUB_FUNDING_MONTHS (or VC_UTILITY_FUNDING_MONTHS) × 1.15
                           ← initial create funds SEVERAL MONTHS upfront (default 6)
                             + 15% buffer. Top-ups use × 1 × 1.15. See §5.4.
        supplierId:        vcStableId           ← stable, same every time
        accountType:       "LDG"               ← lodge card
        type:              "Lodged / Legacy"
        requiresLodged:    true
        fileDate:          <now ISO>
      }]
    }
  → Response: rec[0].id = cpxPaymentId  ← stored permanently as cpxCardIdForDetails
  → Response: rec[0].transactionId     ← stored in VC ledger (for CPX payment lookup)

  FUNDING FORMULA (createVirtualCardInitialPayment):
    fundingMonths = isTopUp ? 1 : VC_SUB_FUNDING_MONTHS | VC_UTILITY_FUNDING_MONTHS
    amount        = round(price × fundingMonths × 1.15, 2)
  Initial creation loads ~6 months of the service upfront so a card survives many
  billing cycles WITHOUT relying on the monthly top-up cron — which is fixed in code
  but still disabled pending settlement verification (see §5.4). Card MINTS with the
  full provisional balance immediately; the
  Good-Funds ACH debit against the customer's Passport settles the NEXT BUSINESS
  DAY, so a large 6× amount (e.g. a $211/mo utility → $1,455.90) can exceed the
  Passport balance and FAIL settlement (NSF) even though the card shows funded.

Step 4: _storePifResult()
  → Store cpxCardIdForDetails on VirtualCard (NEVER overwritten on subsequent top-ups)
  → Write VirtualCardLedgerEntry: entryType='pif_submitted'
  → Write UserLedgerEntry: entryType='vc_funding', status='pending'
    (settled later when CPX fires the 'VCN Generated' / 'Ready' webhook)

Step 5: _fetchCardDetailsWithRetry()
  → GET /payment/v1/decryptCardData/{cpxCardIdForDetails}
  → Returns: virtualCardNumber, CVV, expirationString, limit
  → Retries up to 3× with backoff (card may not be ready immediately)
  → Full PAN never logged or stored in DB

Step 6: _writeAirtableVcDetails()
  → Writes last4, expiry, limit to Airtable VC_Details table
  → This is how ops see card details without touching the DB

Step 7: _updateSubscriptionVcDetails()
  → Stamps vcStableId on UserSubscription
  → Stamps servicePaymentMethod: 'VC' (confirmed)
  → This is the confirmation that the VC is live

5.4 Monthly Top-Up — ✅ FIXED (accountId = lodge-card id)

Trigger: Cron job calls topUpVirtualCardForAllUsers() once per month (top-ups use fundingMonths = 1).

Flow:

For each user:
  Skip if disbursement is paused (dunning gate) or, under VC_TOPUP_SKIP_PAST_DUE,
    if collection is past_due (collect-first policy)
  Find all VirtualCards where status='active'
  For each active VC (_topUpOneVc):
    1. Idempotency: skip if a top-up was already requested this billing period
       (hasTopUpSince) — stops the cron double-firing minting duplicate cards
    2. Resolve the lodge-card id (resolveCpxAccountId: prefer VC.cpxAccountId,
       else CPX payment lookup + cache), then submit the top-up PIF passing
       accountId = cpxAccountId to RELOAD the existing lodge card, plus a fallback
       amount (last funded ledger amount) so a broken sub/price chain doesn't abort
    3. On success: write 'top_requested' ledger entry, update lastKnownBalance
    4. On failure: write 'error' ledger entry

✅ ROOT CAUSE FOUND & FIXED (2026-07-27). The top-up PIF was passing accountId = cpxCardIdForDetails — but that is the details/payment id, not the lodge card's id. CPX rejects it with Failed / "No Active LDG Card found for the accountId …". The correct value is the lodge-card id CPX returns in its buyer/payment records, which we store on the VirtualCard as cpxAccountId. Proven with two back-to-back live PIFs on Olamide2's Max card (****4100):

  • accountId = cpxCardIdForDetailsFailed / No Active LDG Card (no money moved)
  • accountId = cpxAccountId (lodge-card id) → VCN Generated, balance $57.94 → $126.87 (+$68.93), lodge cards under supplier stayed 1, existing card payments 1→2 — it RELOADED the merchant's existing card, no new card minted.

CPX was honouring accountId all along; we were handing it the wrong id. Fix lives in _topUpOneVc / resolveCpxAccountId (virtual-card-orchestration.service.ts).

Still true — the >1-active-card guard: once a supplier has >1 active lodge card, CPX rejects PIFs with "More than 1 active card found with buyerId+supplierId", so duplicates must be deduped (disable extras) before a top-up can succeed. The idempotency guard (step 1) prevents the cron double-fire that used to create them.

Cron status: the monthly @Cron in virtual-card-topup.job.ts is still disabled pending confirmation that the first live top-up settles to Good Funds (T+2). Re-enable after settlement is verified. Until then, cards are funded ~6 months upfront at creation (VC_SUB_FUNDING_MONTHS / VC_UTILITY_FUNDING_MONTHS, §5.3). reconcileUnfundedTopUps writes an unfunded ledger marker + admin alert for any top_requested CPX never confirms within SLA, and a daily pre-charge sweep (runPreChargeVerification, D−2) auto-remediates unfunded cards.

Note: cpxCardIdForDetails is NOT updated on top-up — the first PIF's id is always used for balance/detail lookups. Only the PIF accountId changed (to cpxAccountId).

5.5 Merchant Charges

The utility provider charges the lodge card directly. CPX fires a webhook:

Normal settlement:

CPX webhook: status='Settled'
  → amount: full charged amount
  → VirtualCardLedger: entryType='charge', amount=-chargeAmount
  → UserLedger: entryType='vc_charge', amount=chargeAmount, status='settled'
  → VirtualCard.lastKnownBalance updated

Partial settlement (more common for utilities):

CPX fires TWO events for the same transaction:

Event 1: status='Partially Settled'
  → settlementAmount: the actual authoritative final amount ← USE THIS
  → SCG-specific: may include $1.65 platform fee (captured in metadata.scgExtraCardFee)
  → VirtualCardLedger: entryType='charge'
  → UserLedger: entryType='vc_charge'

Event 2: status='Settled'
  → Also logged, but Partially Settled amount is authoritative
  → Both get separate ledger entries (different eventIds)

Authorization holds:

CPX webhook: status='Authorized' | 'Approved'
  → VirtualCardLedger: entryType='authorization_hold', amount=-holdAmount
  → No UserLedger entry (hold, not settled charge)
  → Reversed when Settled event arrives

5.6 The SCG Platform Fee

Southern California Gas charges a $1.65 convenience fee when a customer pays via virtual card (as opposed to ACH). This fee is applied automatically by SCG on top of the bill amount. It appears in the CPX Partially Settled webhook as an extra amount beyond the subscription amount.

Bundul captures it:

  • metadata.scgExtraCardFee = 1.65 in the VirtualCardLedgerEntry
  • The total settled amount (bill + $1.65) is what gets written to the UserLedger vc_charge entry

This fee is currently absorbed by the system — it is not charged back to the user separately.

5.7 Refunds

CPX webhook: status='Refund' | 'Partially Refunded'
  → VirtualCardLedger: entryType='refund', amount=+refundAmount
  → UserLedger: entryType='vc_refund', amount=refundAmount, direction='credit'
  → VirtualCard.lastKnownBalance updated

5.8 Card Cancellation

Triggered by admin action or subscription cancellation:

cancelServiceCard(vcStableId):
  1. Fetch VirtualCard by vcStableId
  2. If cpxAccountId missing: search CPX payments by pif_submitted transactionId
     (fallback — cpxAccountId stored from 'VCN Generated' webhook)
  3. POST /payment/v1/disableCard { accountId: cpxAccountId }

  QUIRK: CPX returns HTTP 500 with body { statusCode: 413 } on SUCCESS.
  Both { status: 413 } in body and HTTP 500 wrapping a 413 body are
  treated as successful cancellation. Any other error is a real failure.

  4. Set VirtualCard.status = 'cancelled'
  5. VirtualCardLedger: entryType='cancelled'
  6. UserLedger: entryType='vc_cancelled', direction='informational'

What disable actually does (verified live):

  • Sweeps the card balance to $0 — any unspent lodge-card funds are released off the card (verify they return to the customer's Passport, not stranded).
  • Does NOT remove the card from the supplier. The card keeps deleted:false in the buyer's lodgePaymentCards list; it's just inactive/zeroed. This lingering card is what breaks a same-sid re-create (see §5.9).
  • Is not reversible — there is no re-enable/activate endpoint in the codebase.

5.9 Card Re-creation — Cancel + Fresh Supplier SID

Problem (verified live 2026-07-10): cancel-then-recreate on the SAME vcStableId fails. Because disable leaves the old lodge card attached to the supplier, a fresh initial PIF (even with no accountId) resolves to that now-disabled card and CPX returns paymentStatus: Failed — "No Active LDG Card found for the accountId <old card>". No new card is minted; no debit occurs. Clean mints only happen when the supplier has no prior lodge card.

Fix: recreate under a FRESH supplier SID so CPX sees a brand-new supplier with 0 cards → clean single mint:

newSid = "BundulInc-{userId}-{subscriptionId}-r{n}"   (e.g. -r2 = recreate #2)
  1. Disable every live lodge card under the old (canonical) sid  (dedup)
  2. Create a new CPX supplier with newSid  (fresh, 0 cards)
  3. Submit the initial PIF (no accountId) → CPX mints one clean card at 6× amount
  4. Repoint the VirtualCard record: vcStableId / cpxSupplierSid / cpxSupplierId /
     cpxCardIdForDetails / cpxAccountId → new values, status='active'
  5. Stamp UserSubscription.vcStableId = newSid; write pif_submitted ledger entry

Webhook routing self-heals: on the new card's VCN Generated event, body.transactionId matches the new pif_submitted.cpxTransactionId → resolves the VC record → stores the new cpxCardTid (§7.2 path 2 → path 1 thereafter).

⚠ Caveat — two code paths REBUILD the canonical sid (BundulInc-{userId}-{subId}, no suffix) instead of reading the stored vcStableId:

  • createVCForService (and admin reset-and-recreate) → would build the canonical sid and mint a duplicate beside the -r{n} card. Do NOT run reset-and-recreate on a -r{n} sub; re-fund via the fresh-sid path.
  • _cancelVcForSubscription (automation, on sub cancellation) → builds the canonical sid; a cancellation no-ops on a -r{n} card (leaves it active).

All other paths read the stored sid and resolve -r{n} correctly: merchant card-swap (m.vcStableIdUserSubscription.vcStableId), monthly top-ups (vc.vcStableId), CPX webhooks (stored cpxCardTid / supplier ids), and reveal-creds (by subscription).


6. ACH Service Payments

6.1 What It Is

For services that support ACH bank debit (rather than card), Bundul registers the user's Passport-linked bank account as the payment method directly with the provider. The provider then debits that account on their own billing schedule.

6.2 ACH Setup Flow

During automation (Puppeteer-based service onboarding):

1. Automation logs into provider portal
2. Registers user's bank account (from Passport external account details)
3. On success: stampAchPaymentDetails() called

stampAchPaymentDetails(subId, passportAccountId):
  → UserSubscription.achPaymentDetails = {
      passportAccountId,
      registeredAt: now,
      status: 'active'
    }
  → UserSubscription.servicePaymentMethod = 'ACH'
  → UserLedger: entryType='ach_payment_setup', direction='informational', status='settled'
    idempotencyKey: "ach_setup_{subId}"

6.3 ACH Payment Settlement

Provider initiates a debit from the user's Passport-linked external account. Passport fires a webhook when it settles:

Passport webhook: transaction.ach.update, status=COMPLETED

handleUtilityPaymentSettlementFromPassport():
  1. Mark Airtable "Customer Utilities" record as Paid (by resourceId)
  — No ledger write here —

CustomerCashLedger write (async, up to 1 hour later):
  Next hourly LedgerSyncService run picks up the settled entry from Passport
  → narration contains provider name (e.g. "WEB ACH Debit from SO CAL EDISON CO...")
  → LedgerCategorizationService._resolveAchUtility() matches narration provider
    against UserPassportSubscription.subscriptions[].name using smart scoring
    (stripped-substring + prefix-aware token Jaccard similarity)
  → Writes CustomerCashLedger: category='ach_utility_pull',
    subscriptionId + subscriptionName from matched subscription
    billingPeriod shifted -1 month if subscription is arrears-billed

No pending phase for ACH service charges: Unlike subscription charges (which go SCHEDULED → COMPLETED), utility ACH debits are initiated by the provider and Bundul only learns about them when they complete. There is no SCHEDULED event to create a pending ledger entry.

ACH narration format: Passport truncates the provider name to 16 characters per ACH standard. The categorizer handles this with two strategies: (1) stripped substring match — removes non-alphanumeric chars and checks bidirectional containment; (2) prefix-aware token Jaccard — matches abbreviated tokens like "SO" ↔ "SOUTHERN", "CAL" ↔ "CALIFORNIA". Match threshold: 0.55.


7. Webhooks

7.1 Passport Webhook

Endpoint: POST /webhook/passport

Known issue (fixed): Passport sends eventCreated as an ISO 8601 string ("2026-03-15T10:30:00Z"). Old code did new Date(Number(eventCreated)) which produced NaNRangeError: Invalid time value. Fixed with a 3-step defensive parser: try as number, then as ISO string, fallback to now.

Event Types:

transaction.ach.update

The primary event. Carries the state of an ACH transaction.

Payload fields:
  resourceName     // transaction ID (used as sourceId in ledger)
  status           // SCHEDULED | COMPLETED | FAILED | CANCELLED
  amount
  externalId       // set by Bundul when creating the charge
  sourceAccountId  // the account being debited
  destinationAccountId // the account receiving funds

ExternalId routing table:

externalId prefix Charge type Ledger entry
BUNDUL-SUB-{userId} Monthly subscription charge recurring_charge_subscription
BUNDUL-FEE-{userId} Monthly Bundul fee recurring_charge_bundul_fee
BUNDUL-TRUEUP-{trueUpRecordId} True-up deposit or withdrawal trueup_deposit / trueup_withdrawal
BUNDUL-ONE-TIME-{userId} One-time charge one_time_charge
(none — sourceAccountId match) Utility ACH service charge ach_service_charge

State machine:

SCHEDULED
  → Create pending UserLedgerEntry
  → sourceId = resourceName (executionTxnId)

COMPLETED
  → settleBySourceId(resourceName)
  → Fallback: settleByUserAndEntryType(userId, entryType)
    (legacy entries created before sourceId tracking — matched by user+type)

FAILED
  → failBySourceId(resourceName)
  → If one_time_charge: trigger retry logic

transaction.book.update / transaction.book.create

Bundul internal book transfers (fee accounting between Bundul accounts within Passport). Logged but do not produce user-facing ledger entries.


7.2 CPX Card Event Webhook

Endpoint: POST /webhook/card-event (also stored as POST /webhook/cpx-card-event)

Routing: CPX webhooks carry two identifiers that Bundul uses for routing:

Field What it is Reliability
body.tid Per-card stable UUID assigned by CPX. Same value across ALL events for a given card. Reliable once stored on VirtualCard
body.supplier.sid Should be vcStableId but is always empty in actual webhooks — never use. Unusable
body.supplier.id CPX UUID for the user's supplier — per-user, not per-card. Cannot identify a specific VC. Wrong granularity
body.transactionId On VCN Generated events only: equals the pif-{…} value we set as transactionId in the PIF submission. Stored in VCLedger.pif_submitted.cpxTransactionId. One-shot — only on VCN Generated

Resolution order (_resolveVirtualCard(body, status)):

1. body.tid is set → VirtualCard.findOne({ cpxCardTid: tid })
   → Hit on first sync after the VCN Generated event stores the tid

2. status === 'VCN Generated' AND body.transactionId is set
   → VCLedger.findOne({ cpxTransactionId: transactionId, entryType: 'pif_submitted' })
   → Resolves to VirtualCard via ledger.virtualCardId
   → Then stores tid on VirtualCard (so future events use path 1)

3. No match → event is logged (CardEvent) but no ledger entry written; warn to Sentry

Idempotency: Every event is written to the CardEvent collection with a unique index on eventId. Duplicate deliveries (CPX retries) are silently dropped before any processing.

Status → Action mapping:

FUNDING statuses: 'VCN Generated', 'Ready', 'Active'
  → Store vcnReference on VirtualCard (for audit)
  → Store cpxAccountId on VirtualCard (needed for cancellation)
  → VirtualCardLedger: entryType='funding' (initial) or 'top_up' (subsequent)
  → UserLedger: entryType='vc_funding' or 'vc_top_up', status='settled'
  → Update lastKnownBalance

CHARGE statuses: 'Settled', 'Partially Settled'
  → Amount: negative (outflow from card)
  → Partially Settled: use settlementAmount field (authoritative)
  → VirtualCardLedger: entryType='charge'
  → UserLedger: entryType='vc_charge', status='settled'
  → Update lastKnownBalance
  → SCG: capture $1.65 platform fee in metadata

REFUND statuses: 'Refund', 'Partially Refunded'
  → Amount: positive (return to card)
  → VirtualCardLedger: entryType='refund'
  → UserLedger: entryType='vc_refund', status='settled'
  → Update lastKnownBalance

AUTHORIZATION statuses: 'Authorized', 'Approved', 'Partially Authorized'
  → Amount: negative (hold)
  → VirtualCardLedger: entryType='authorization_hold'
  → No UserLedger entry (not settled)

CANCELLED statuses: 'Cancelled', 'Disabled', 'VCN Disabled'
  → VirtualCard.status = 'cancelled'
  → VirtualCardLedger: entryType='cancelled'
  → UserLedger: entryType='vc_cancelled', direction='informational'

ERROR statuses: 'Declined', 'Denied', 'Failed', 'Error'
  → VirtualCardLedger: entryType='error'
  → No UserLedger entry
  → Logged + Sentry alert

INFO statuses: 'Pending', 'Queued', 'Scheduled', 'Email Sent'
  → No ledger entries
  → Logged only

7.3 Plaid Webhook

Endpoint: POST /webhook/plaid

SYNC_UPDATES_AVAILABLE
  → syncUserTransactions(userId)
  → Calls /transactions/sync API
  → Stores results in UserPlaidTransactions
  → Emits PLAID_DATA_SYNCED event

RECURRING_TRANSACTIONS_UPDATE
  → syncRecurringTransactions(userId)
  → Calls /transactions/recurring/get API
  → Stores patterns in UserPlaidRecurringTransactions
  → Emits PLAID_DATA_SYNCED event
  → IMPORTANT: wrapped in try-catch — Plaid 5xx errors log as warning
    and return HTTP 200 to Plaid (prevents Plaid from infinite retry loop)

ITEM.ERROR / ITEM_LOGIN_REQUIRED
  → Mark PlaidToken.status = 'invalid'
  → Generate hosted Plaid Link re-authentication URL
  → Send push notification to user
  → Send email to user with re-auth link
  → User must re-authenticate before Plaid data is available again

8. Ledger System

Bundul uses two ledgers with distinct roles. Neither stores pending/in-flight state — financial finality comes from Passport webhooks and is reflected at sync time.

8.1 CustomerCashLedger — The Financial Source of Truth

Collection: customercashledgers

Synced hourly from Passport's /v1/ledger/list API. Each document represents one settled financial event. Entries are immutable and idempotent — the sync cron upserts on passportLedgerEntryId.

{
  userId:                  ObjectId           // index
  passportLedgerEntryId:   string             // unique index — Passport entry id, idempotency key
  passportAccountId:       number             // which Passport account this came from
  passportScheduleId?:     string             // schedule.id → links to recurring transaction
  billingPeriod:           string             // "YYYY-MM" — derived from ledgerDate
  entryDate:               Date               // Passport's ledgerDate
  syncedAt:                Date               // when we wrote this row

  category: 'one_sub_collection'             // user's monthly bundle payment (ACH COLLECT, recurring)
           | 'bundul_fee'                    // Bundul platform fee debit (separate recurring charge)
           | 'vc_charge'                     // CPX/Priority Commerce settled merchant charge
           | 'ach_utility_pull'              // ACH pull by utility provider directly
           | 'refund'                        // any reversal or refund
           | 'one_time_collection'           // retry or one-off catch-up charge
           | 'trueup_deposit'               // Bundul true-up BOOK CREDIT to customer
           | 'trueup_debit'                 // true-up collection from customer
           | 'unclassified'                 // fallback — needs manual review

  direction:               'credit' | 'debit'
  amount:                  number             // always positive; direction carries the sign
  method:                  string             // 'ACH' | 'BOOK' | etc.

  subscriptionId?:         ObjectId           // populated for vc_charge + ach_utility_pull
  subscriptionName?:       string             // denormalized at sync time
  subscriptionBreakdown?:  [{                 // populated for one_sub_collection
    subscriptionId:        ObjectId
    subscriptionName:      string
    subscriptionAmount:    number
    subscriptionVCTransactionId?: string
  }]

  passportRawEntry:        Object             // full raw Passport entry, stored for debugging
}

8.2 How the Sync Works

LedgerSyncService runs hourly via cron (EVERY_HOUR). A guard flag prevents overlapping runs.

For each active user (passportAccountId exists):

  1. Determine cursor:
     Latest CustomerCashLedger.entryDate for this user − 2 days (overlap window)
     If no entries yet: now − 90 days (default lookback)
     The 2-day overlap catches late-arriving Passport entries that may have
     been missed if their entryDate slightly precedes the previous cursor.

  2. Call passportService.listLedger({
       accountId: passportAccountId,
       lastUpdatedOn: { gte: cursor, lte: now }
     })
     Pages up to 50 entries × 20 pages per user per run (safety cap).

  3. For each entry:
     a. Check existing CustomerCashLedger row by passportLedgerEntryId
     b. Skip only if: same amount AND same status AND not unclassified/unattributed
        (unclassified entries always trigger a re-categorize attempt)
     c. LedgerCategorizationService.categorize(entry, userId)
     d. Upsert CustomerCashLedger (idempotent on passportLedgerEntryId)

  4. _recategorizeStale(userId): scans ALL existing entries where
       category='unclassified'  OR
       category='vc_charge' with no subscriptionId  OR
       category='ach_utility_pull' with no subscriptionId
     Re-runs categorize() against the stored passportRawEntry for each.
     Updates only rows where category/subscriptionId changed.
     This makes the sync self-healing: entries outside the cursor window
     (not fetched again from Passport) are still re-categorized once
     attribution data arrives (e.g. CPX webhook writes VCLedger charge entry).

8.3 Categorization Priority Chain

LedgerCategorizationService.categorize() applies these rules in order, stopping at the first match:

1. schedule.id matches UserPassportSubscription.passportRecurringChargeId
   → category: 'one_sub_collection'
   → subscriptionBreakdown: from UserPassportSubscription.subscriptions[]

2. schedule.id matches UserPassportSubscription.passportRecurringFeesChargeId
   → category: 'bundul_fee'

3. transactionId matches PassportPaymentRecord.transactionId
   → category: 'one_time_collection'

4. direction='debit' AND narration does NOT contain "PRIORITY COMMERC"
   AND narration contains " from " (provider name pattern)
   → _resolveAchUtility(): score narration provider against all subscriptions
     using stripped-substring + prefix-aware token Jaccard (threshold 0.55)
   → Best match: category='ach_utility_pull', subscriptionId from matched sub
     billingPeriod shifted -1 month if subscription is arrears-billed
   → No match: falls through to next rule

5. narration contains "PRIORITY COMMERC" (CPX/Priority Commerce identifier)
   → _resolveVcCharge(): find VirtualCardLedger charge entries
     (userId + entryType='charge' + entryDate ±2 days + amount within $0.02)
   → If single match: category='vc_charge', subscriptionId from VirtualCard
   → If multiple same-price: exclude already-claimed subscriptionIds in
     this billingPeriod (CustomerCashLedger.distinct('subscriptionId')),
     pick first unclaimed
   → If no VCLedger charge entries at all (historical gap — CPX webhook missed):
     single-VC fallback: scan pif_submitted entries for this user; if exactly
     one unique subscriptionId exists, attribute to that subscription
   → If still no match: category='vc_charge', subscriptionId=null
     (re-categorized on next sync via _recategorizeStale once CPX writes charge entry)

6. method='BOOK' AND direction='credit'
   → category: 'trueup_deposit'

7. externalId starts with "BUNDUL-TRUEUP"
   → direction debit: category='trueup_debit'
   → direction credit: category='trueup_deposit'

8. Fallback → category: 'unclassified'

8.4 VirtualCardLedgerEntry — Operational Record

Collection: virtualcardledgerentries — operational log of every CPX card lifecycle event. Serves as the bridge for VC charge attribution during the hourly sync (rule 5 above).

entryType What triggers it Location
pif_submitted PIF submission success (initial card creation) _storePifResult() in VirtualCardOrchestrationService
top_requested PIF submission success (top-up) topupVirtualCard()
error PIF submission failure or top-up failure error handlers
funding CPX VCN Generated/Ready webhook (new card) _routeEventToLedger()
top_up CPX VCN Generated/Ready webhook (existing card) _routeEventToLedger()
charge CPX Settled/Partially Settled webhook _routeEventToLedger()
refund CPX Refund/Partially Refunded webhook _routeEventToLedger()
authorization_hold CPX Authorized/Approved webhook _routeEventToLedger()
cancelled CPX Cancelled/Disabled webhook or admin action _routeEventToLedger() / _cancelVcRecord()

Attribution bridge: The charge entry links a Passport PRIORITY COMMERC debit to a specific subscription. The sync categorizer queries VirtualCardLedger for matching charge entries (userId + date ±2 days + amount within $0.02). If no match exists yet (CPX webhook hasn't arrived), the entry is written as vc_charge with subscriptionId=null and re-categorized on the next sync pass once attribution is available.

8.5 Supporting Anchor Documents

These documents are not ledgers — they are the categorization anchors the sync reads:

Document Role in Sync
UserPassportSubscription passportRecurringChargeId, passportRecurringFeesChargeId, and subscriptions[] breakdown — categorizes recurring debits and populates subscriptionBreakdown
PassportPaymentRecord transactionId — categorizes one-time and retry charges
BundulSupportedSubscription.billerNarrationKey Stable narration pattern for ACH utility pulls
UserPassportSubscription.subscriptions[].subscriptionVCTransactionId Links VC charges back to subscription for attribution

8.6 addToOneSub in the Sync Flow

When a user adds services mid-cycle (bundulType='Addition'):

  1. A new Passport recurring charge is created and its passportRecurringChargeId is added to UserPassportSubscription.
  2. UserPassportSubscription.subscriptions[] is updated with the new services immediately.
  3. No CustomerCashLedger write happens at addToOneSub time.
  4. On the next sync, the new recurring charge appears in Passport with its own schedule.id, which matches the updated UserPassportSubscription. The sync writes a one_sub_collection entry with the updated subscriptionBreakdown.

8.7 Retry Logic in the Sync Flow

When a payment retry occurs (payment-retry.service.ts):

  1. A new Passport one-time charge is created.
  2. A PassportPaymentRecord is written with the new transactionId.
  3. No CustomerCashLedger write happens at retry time.
  4. On the next sync, the charge appears in Passport and matches PassportPaymentRecord.transactionId → categorized as one_time_collection.

9. True-Up (Quarterly Reconciliation)

9.1 What It Reconciles

Each month, Bundul collects a fixed bundle amount from the user via Passport. The actual utility bills charged to their services may differ. True-Up reconciles the two at the end of each quarter.

User overpaid  → Bundul deposits the difference to their external account
User underpaid → Bundul collects the difference from their external account

Single source of truth: TrueUpService reads only CustomerCashLedger. It does not query Airtable, VirtualCardLedger, or any other collection. All VC charges and ACH utility payments are fully attributed at sync time by LedgerCategorizationService before the true-up calculation runs.

9.2 Delta Calculation

TrueUpService.calculateForUser(userId, quarter) reads CustomerCashLedger directly:

quarter format: "2026-Q1" (maps to Jan 1 – Mar 31, 2026)

For each billingPeriod (YYYY-MM) in the quarter:

  collected = SUM(amount WHERE category='one_sub_collection' AND direction='debit')
            + SUM(amount WHERE category='one_time_collection' AND direction='debit')

  platformFee:
    If passportRecurringFeesChargeId IS NOT NULL (separate fee charge):
      platformFee = SUM(amount WHERE category='bundul_fee' AND direction='debit')
    Else (embedded fee user):
      platformFee = collected - SUM(subscriptionBreakdown[].subscriptionAmount)

  netSpend = SUM(amount WHERE category IN ('vc_charge','ach_utility_pull') AND direction='debit')
           - SUM(amount WHERE category='refund' AND direction='credit')

  delta = collected - platformFee - netSpend

Incomplete month: netSpend == 0 AND collected > 0
  → Excluded from quarter total (missing spend data is ambiguous)

Quarter delta = SUM(monthly deltas for complete months)

Direction:
  delta > TOLERANCE   → 'deposit'    (user overpaid, refund them)
  delta < -TOLERANCE  → 'withdrawal' (user underpaid, collect)
  |delta| ≤ TOLERANCE → 'balanced'   (no transaction needed)

9.3 Admin Approval Flow

True-up requires explicit admin approval before any money moves:

1. TrueUpRecord created:
   {
     userId, quarter, delta, direction,
     status: 'pending_approval',
     approvalToken: <uuid>,
     tokenExpiresAt: now + 72h,
     monthlyBreakdown: [{ month, collected, platformFee, netSpend, delta, isIncomplete }]
   }

2. Email sent to admin(s):
   - User details, quarter, delta amount, direction
   - Approve link: GET /admin/trueup/{token}/approve
   - Decline link: GET /admin/trueup/{token}/decline?reason=...

3. Slack notification (same summary)

4. Admin clicks link:
   - Token validated (expires in 72h)
   - Already actioned: idempotent HTML response
   - Approve → executeApproved() → POST /admin/trueup/:id/execute
   - Decline → TrueUpRecord.status = 'declined', no money moves

9.4 Execution

TrueUpService.executeApproved(record):

Case: balanced
  → TrueUpRecord.status = 'settled'
  → Send user analysis email
  → Done (no money moves)

Case: no external account linked
  → TrueUpRecord.status = 'failed'
  → Log error

Case: deposit (user overpaid — refund them)
  → Passport BOOK transfer or ACH SEND:
      source: BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID
      destination: user's passportAccountId (BOOK) or external account (ACH SEND)
  → TrueUpRecord: status='executing', passportTransactionId stored
  → CustomerCashLedger: next sync will write 'trueup_deposit' entry

Case: withdrawal (user underpaid — collect from them)
  → Passport ACH COLLECT:
      source: user's external account (passportExternalAccountId)
      destination: BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID
      externalId: "BUNDUL-TRUEUP-{trueUpRecordId}"
  → TrueUpRecord: status='executing', passportTransactionId stored
  → CustomerCashLedger: next sync will write 'trueup_debit' entry

After initiating:
  → Send user email with full quarter analysis breakdown

9.5 Settlement

Passport fires transaction.ach.update when the true-up ACH settles:

Webhook: externalId = "BUNDUL-TRUEUP-{trueUpRecordId}"

COMPLETED:
  → TrueUpRecord.status = 'settled'
  → Send user confirmation email
  → CustomerCashLedger sync will pick up the entry on next run

FAILED:
  → TrueUpRecord.status = 'failed'
  → Log error + Sentry alert

10. Admin — True-Up Charge Execution

10.1 Endpoint

POST /admin/trueup/:trueUpRecordId/execute
Auth: Basic (adminUsername / adminPassword)
Body: { direction: 'debit' | 'credit', confirmedAmount: number }

The confirmedAmount must match TrueUpRecord.delta (within $0.01 tolerance) as a safety guard against stale data. If they diverge, the endpoint returns 409 and the caller must re-calculate.

10.2 Debit Direction (User Owes Bundul)

The user's collected One Sub amount was less than what was actually spent. Bundul pulls the difference.

Source:      customer's external bank account
             (passportAccountDetails.externalAccountDetails[0].passportExternalAccountId)

Destination: BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID (env var)

Method:      Passport ACH COLLECT
             POST /v1/transaction
             {
               source: { externalAccount: { id: passportExternalAccountId } }
               destination: { account: { id: BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID } }
               method: 'ACH'
               amount: confirmedAmount
               externalId: "BUNDUL-TRUEUP-{trueUpRecordId}"
               purpose: 'True-Up Collection'
             }

Result:
  → TrueUpRecord.status = 'executing'
  → TrueUpRecord.passportTransactionId = new transaction id
  → Settles via Passport webhook

10.3 Credit Direction (Bundul Owes User)

The user's One Sub collected more than was spent. Bundul sends the difference back.

For immediate settlement (preferred):
  Passport BOOK transfer (instant, no ACH delay):
  {
    source: { account: { id: BUNDUL_TRUEUP_PASSPORT_ACCOUNT_ID } }
    destination: { account: { id: user.passportAccountDetails.passportAccountId } }
    method: 'BOOK'
    amount: confirmedAmount
    purpose: 'True-Up Refund'
  }

User can then withdraw from their Passport account to their external bank.

Result:
  → TrueUpRecord.status = 'settled' (BOOK transfers settle immediately)
  → Send user email with refund confirmation

10.4 User Identification

Both directions require the following from the user's record:

user.passportAccountDetails.passportAccountId          // Passport wallet ID
user.passportAccountDetails.externalAccountDetails[0]  // linked external bank
  .passportExternalAccountId                           // Passport's ID for external account

If the user has no externalAccountDetails, the debit direction fails. The credit direction (BOOK) still works since it goes to the Passport wallet.


10a. Things that shipped after this document was first written

Added 1 September 2026. Everything above describes the original One Sub money flow and is still accurate. Four things have been built on top of it since, and none of them were described here — which is what prompted this section.

Each one is summarised rather than fully specified. The detailed design for each lives in its frozen record under docs/decisions/, and the code is the final authority.

Split payments — one One Sub, two collections

A customer whose bills fall at different points in the month can have their One Sub split into two payments instead of one lump sum.

  • The rule. Part 1 collects bucket 1 at the next scheduled pull D. Part 2 collects bucket 2 at D + 15, clamped to day ≤ 28 so it cannot land on a date some months lack.
  • Fees sit on part 1 only. Part 2 carries the service amounts and no fee.
  • Conversion moves no money. Converting an existing single One Sub only reschedules pulls that were already authorised. Anchoring part 2 at "today + 15" rather than D + 15 would double-charge the cycle the previous collection had already covered — the code refuses to convert against a stale anchor.
  • Code: src/payment/orchestration/split-conversion.service.ts. Design: docs/decisions/split-conversion-plan.md.

Promotions and the free month

  • A fee-free window can be granted to a customer, tracked on the UserPassportSub record.
  • updateRecurringChargesAfterFreeMonth() in src/payment/records/user-payment.service.ts moves them back onto normal pricing when the window ends. It runs daily at 03:00 UTC (src/jobs/jobs/user-payment.job.ts).
  • Reading the fee-free window from the charge start date would restart the window and re-grant the free month, so it is deliberately read from elsewhere. There are comments at both sites explaining this; do not "simplify" them.
  • Discount codes and promotions have their own module, src/promotions/, with an admin controller.

Utility re-pricing

Utility bills change. Two scheduled jobs keep a customer's One Sub in line with what their utilities now actually cost:

Job Schedule (UTC) What it does
repricing-review 1st of Jan/Apr/Jul/Oct, 07:00 Reviews utilities for a price change
repricing-apply daily, 05:00 Applies approved changes

Code: src/utility-insights/services/repricing*.ts and price-change-apply.service.ts. Design: docs/decisions/utility-bill-insights-plan.md.

Bank reconnection

When a customer's bank connection breaks, collection cannot proceed. There is now a customer-facing route out of that state rather than a silent failure, and the balance is only checked at the point the money is actually needed rather than eagerly. See docs/explanation/bank-connection-failures.md.


11. Data Model Reference

User.passportAccountDetails

{
  customerPassportId: number        // Bundul's customer ID in Passport
  passportAccountId: number         // User's Passport wallet ID (receives ACH debits)
  passportAccountNumber: string     // Encrypted bank account number
  passportAccountRoutingNumber: string // Encrypted routing number
  externalAccountDetails: [{
    userAccountId: string           // Plaid account ID
    passportExternalAccountId: number // Passport's ID for this external account
  }]
  cpxBankAccountId: string          // Passport account registered in CPX as funding source
}

VirtualCard (key fields)

{
  vcStableId: string                // "BundulInc-{userId}-{subscriptionId}"
                                    // may carry a "-r{n}" suffix after a re-create (§5.9)
  userId: ObjectId
  subscriptionId: ObjectId
  serviceName: string
  status: 'active' | 'cancelled'
  cpxSupplierId: string             // CPX supplier UUID (resolved from CPX)
  cpxSupplierSid: string            // = vcStableId (the CPX supplier sid)
  cpxCardTid: string                // CPX per-card stable UUID — PRIMARY webhook routing key
                                    // Stored on first VCN Generated event; indexed
  cpxCardIdForDetails: string       // First PIF's rec[0].id — NEVER changes
  cpxAccountId: string              // Stored from VCN Generated webhook — used for cancellation
  cpxVcnReference: string           // VCN reference from CPX (audit)
  lastKnownBalance: number          // Updated after every CPX event
}

CustomerCashLedger (all fields)

See Section 8.1 for the full schema.

Key query patterns:

// True-up: get all financial events for a user in a billing period
db.customercashledgers.find({
  userId: ObjectId(userId),
  billingPeriod: { $in: ['2026-01', '2026-02', '2026-03'] }
})

// Unclassified entries (need manual review)
db.customercashledgers.find({ category: 'unclassified' })

// VC charges for a specific subscription
db.customercashledgers.find({ category: 'vc_charge', subscriptionId: ObjectId(subId) })

TrueUpRecord (key fields)

{
  userId: ObjectId
  quarter: string                   // "2026-Q1" (YYYY-QN format)
  delta: number                     // Positive = deposit (owed to user), negative = withdrawal (owed by user)
  direction: 'deposit' | 'withdrawal' | 'balanced'
  status: 'pending_approval' | 'approved' | 'declined' | 'executing' | 'settled' | 'failed'
  approvalToken: string             // UUID, expires in 72h
  tokenExpiresAt: Date
  passportTransactionId?: string    // Set when Passport transaction is initiated
  noExternalAccount: boolean        // True if user has no linked external bank
  monthlyBreakdown: [{
    month: string                   // 'YYYY-MM'
    collected: number               // One Sub + one-time charges
    platformFee: number             // Bundul fee (separate or derived)
    netSpend: number                // VC charges + ACH utility - refunds
    delta: number                   // collected - platformFee - netSpend
    isIncomplete: boolean           // netSpend == 0 when collected > 0
  }]
}

12. Key Quirks & Edge Cases

CPX Card Cancellation Returns 500 on Success

POST /payment/v1/disableCard returns HTTP 500 with body { statusCode: 413 } after successfully disabling a lodge card. This is a CPX API quirk. Both formats are treated as success:

  • HTTP 200 with any body → success
  • HTTP 500 with body { statusCode: 413 } → success
  • HTTP 500 with body { ... } containing statusCode: 413 → success
  • Any other error → real failure, logged to Sentry

Partially Settled Fires Before Settled

For utility charges, CPX fires two webhook events for a single merchant transaction. Partially Settled arrives first and carries the authoritative final charge amount in settlementAmount. The subsequent Settled event also arrives and is logged, but its amount may differ. Always use Partially Settled.settlementAmount as the true charge amount.

SCG $1.65 Convenience Fee

Southern California Gas applies a $1.65 platform fee when billing via virtual card. It appears as additional amount in the Partially Settled event. It is captured in VirtualCardLedgerEntry.metadata.scgExtraCardFee and included in the vc_charge UserLedger amount (user's bill + $1.65 is the total vc_charge). Currently absorbed — not billed back to the user.

Recurring Charge Settlement Fallback

The SCHEDULED webhook creates a pending UserLedgerEntry with sourceId = executionTxnId. The subsequent COMPLETED webhook settles it via settleBySourceId(executionTxnId). However, older entries (created before sourceId tracking was introduced) used a different sourceId. For those, the fallback settleByUserAndEntryType(userId, entryType) is used — it settles the oldest pending entry of that type for the user.

billingPeriod vs occurredAt in True-Up

The true-up calculation groups entries by billingPeriod (the service month the charge covers), not by occurredAt (when the charge was processed). This means a charge processed on January 31st for December services is correctly attributed to December's reconciliation bucket, not January's.

Plaid Token Re-Auth Loop

When a Plaid token becomes invalid (ITEM_LOGIN_REQUIRED), the token is marked invalid and the user is notified. Until the user re-authenticates via the hosted Plaid Link URL, all Plaid data fetches for that user fail. The system catches 'No valid Plaid access token found' errors in report generation and returns empty data rather than throwing — preventing report failures from cascading.

E11000 Race on Supported Subscription Creation

getSupportedSubscriptionsForUser() uses bulkWrite with ordered: false to upsert supported subscriptions. In concurrent scenarios where multiple requests hit simultaneously, all writes may be duplicates and the MongoBulkWriteError is caught and treated as success (the documents already exist, which is the correct state).

Passport Webhook eventCreated Is ISO, Not Unix

Passport sends eventCreated as an ISO 8601 string ("2026-03-15T10:30:00Z"). Treating it as a unix timestamp (Number("2026-03-15T10:30:00Z") = NaN) and passing to new Date(NaN) throws RangeError: Invalid time value. The fix uses a 3-step parser: try as unix number → try as ISO string → fallback to now.

VC Charges Self-Heal on the Next Sync Pass

If the CPX Settled/Partially Settled webhook hasn't fired when a PRIORITY COMMERC Passport debit is first synced, the row is written as category='vc_charge' with subscriptionId=null. On every subsequent sync run the no-op check explicitly looks for unattributed vc_charge entries (subscriptionId=null) and forces a re-categorize attempt, so once the CPX webhook arrives and writes the VirtualCardLedgerEntry the next hourly sync will fill in the attribution automatically. Manual intervention is only needed if the CPX webhook never fires (check Sentry/logs for card event errors).

Embedded Fee Users vs Separate Fee Users

Some users have passportRecurringFeesChargeId = null (embedded fee). Their Bundul platform fee is folded into the same recurring charge as their subscription amount. For these users, the sync categorizes the single recurring charge as one_sub_collection, and the true-up derives platformFee arithmetically: collected - SUM(subscriptionBreakdown[].subscriptionAmount). Do not create a separate Bundul fee charge for these users — it would cause double-counting.

Passport BOOK CREDITs from True-Up vs Bundul Internal Transfers

BOOK CREDIT entries in the Passport ledger can be either: (a) a true-up deposit Bundul sent to the user, or (b) an internal Bundul accounting transfer. The categorizer distinguishes them by externalId prefix (BUNDUL-TRUEUP-*trueup_deposit) and falls back to trueup_deposit for any BOOK CREDIT. If Bundul starts using BOOK for other purposes, add an explicit externalId pattern to the categorizer.

CPX supplier.sid Is Always Empty

CPX webhook payloads carry a supplier object with sid and id fields. supplier.sid — which was supposed to equal our vcStableId — is always empty string in production webhooks. supplier.id is a per-user UUID shared across all VCs for that user, so it cannot identify a specific card. Routing relies entirely on body.tid (per-card stable UUID) with a body.transactionIdpif_submitted VCLedger lookup as fallback on VCN Generated events.

Top-Up accountId Must Be the Lodge-Card id (cpxAccountId), Not cpxCardIdForDetails — FIXED

Earlier belief was that CPX ignored accountId and minted a new card per top-up. Not true — the code was passing the wrong id. A top-up PIF must set accountId to the lodge-card id (stored on the VC as cpxAccountId, the id CPX returns in its buyer/payment records), NOT cpxCardIdForDetails (the details/payment id, which CPX rejects with "No Active LDG Card found for the accountId …"). Passing cpxAccountId reloads the merchant's existing card in place — proven live 2026-07-27 on Olamide2's Max card (balance rose on the existing card, no new card minted). See §5.4. (Cron still off pending Good-Funds settlement of the first live top-up.)

Cancel + Recreate on the Same SID Fails — Use a Fresh SID

A disabled lodge card stays attached to its supplier (deleted:false), so a fresh PIF on that supplier resolves to the dead card and fails with "No Active LDG Card found for the accountId …". Clean mints require a supplier with 0 prior cards. Recreate under a fresh sid suffix BundulInc-{userId}-{subId}-r{n} and repoint the DB record (§5.9). Two paths rebuild the canonical sid (createVCForService / reset-and-recreate, _cancelVcForSubscription) and would dup or no-op — don't run reset-and-recreate on a -r{n} sub.

disableCard Sweeps the Balance and Is One-Way

Beyond the 500-with-413-body success quirk: disableCard zeroes the card balance (unspent lodge funds are released — verify they return to the customer's Passport), leaves the card listed (deleted:false), and there is no re-enable endpoint. To change an existing card's amount without minting a new one, reload the still-active card with a PIF whose accountId = cpxAccountId (the lodge-card id, §5.4) rather than disabling first.

6× Upfront Funding: Instant Card Balance, Next-Day Settlement (NSF Risk)

On both create and top-up the lodge card shows the full provisional balance immediately, but the Good-Funds ACH debit against the customer's Passport settles the next business day. A large 6× amount on a big utility (e.g. $211/mo → $1,455.90) can exceed the Passport balance and fail settlement (NSF) even though the card looks funded. Confirm the Passport is funded before recreating large-ticket VCs at 6×.

Duplicate Lodge Cards Block Top-Ups

Once a supplier has more than one active lodge card, CPX rejects PIFs with "More than 1 active card found with buyerId+supplierId". Duplicates arise from the top-up-mints-new-card bug and cron double-fires. Dedup (disable the extras) before re-funding; the re-create flow (§5.9) disables all live cards under the old sid first.