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
- Overview
- External Systems
- Subscriptions
- Payment Method Routing — VC vs ACH
- Virtual Cards — Full Lifecycle
- ACH Service Payments
- Webhooks
- Ledger System
- True-Up (Quarterly Reconciliation)
- Admin — True-Up Charge Execution 10a. Things that shipped after this document was first written
- Data Model Reference
- Key Quirks & Edge Cases
1. Overview
Bundul is a subscription bundling platform. Its financial job is simple in concept, complex in execution:
- Collect a monthly fee from the user via ACH (through Passport)
- 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)
- 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):
- Safe window (deterministic risk gate). We compute a window
[today → windowEnd]the customer must choose within.windowEndis 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}").
- 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:
- VirtualCard record identifier — indexed field on the
VirtualCardcollection - UserSubscription stamp — written to
UserSubscription.vcStableIdafter VC creation confirming the link
Note:
vcStableIdwas previously used assupplier.sidfor CPX webhook routing, butsupplier.sidis always empty in CPX webhooks. CPX webhook routing now usestid(see Section 7.2).
4.3 Routing Validation
Before any payment execution, the system validates:
- If
servicePaymentMethod === 'VC'→ must havevcStableId→ else error, trigger VC creation - If
servicePaymentMethod === 'ACH'→ must haveachPaymentDetails→ 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 = cpxCardIdForDetails→Failed / 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 cardpayments 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.65in the VirtualCardLedgerEntry- The total settled amount (bill + $1.65) is what gets written to the UserLedger
vc_chargeentry
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:falsein the buyer'slodgePaymentCardslist; 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.vcStableId ← UserSubscription.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 NaN → RangeError: 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'):
- A new Passport recurring charge is created and its
passportRecurringChargeIdis added toUserPassportSubscription. UserPassportSubscription.subscriptions[]is updated with the new services immediately.- No
CustomerCashLedgerwrite happens at addToOneSub time. - On the next sync, the new recurring charge appears in Passport with its own
schedule.id, which matches the updatedUserPassportSubscription. The sync writes aone_sub_collectionentry with the updatedsubscriptionBreakdown.
8.7 Retry Logic in the Sync Flow
When a payment retry occurs (payment-retry.service.ts):
- A new Passport one-time charge is created.
- A
PassportPaymentRecordis written with the newtransactionId. - No
CustomerCashLedgerwrite happens at retry time. - On the next sync, the charge appears in Passport and matches
PassportPaymentRecord.transactionId→ categorized asone_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 atD + 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 + 15would 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
UserPassportSubrecord. updateRecurringChargesAfterFreeMonth()insrc/payment/records/user-payment.service.tsmoves 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
{ ... }containingstatusCode: 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.transactionId → pif_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.