Bundul
Internal
Browse docs
Waiting for review

explanation

Virtual Card (VC) Lifecycle

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

Virtual Card (VC) Lifecycle

Concepts

Term What it is
Buyer Bundul Inc. — the entity that funds the cards. One globally, identified by app.bundulId (CPX UUID e.g. 27d1644c-5d8a-5605-9112-796e33507ef1)
Supplier The payee / lodge card holder. One per (user × subscription). Identified by vcStableId (BundulInc-{userId}-{subId}) which we give CPX as the sid
Lodge card (LDG) The reusable virtual card tied to a supplier. Not single-use. Money is loaded onto it via a PIF
PIF Payment Instruction File — moves money from Bundul's bank account onto a lodge card
vcStableId Our stable string key: BundulInc-{userId}-{subId}. Used as CPX sid and as supplierId/payee.id in all PIFs
cpxCardIdForDetails UUID returned by the first PIF. Stored permanently. Used to fetch card details and balance forever after
accountId CPX's internal lodge card account ID. Fetched from the payment record. Used only for cancellation

0. Auth — Bearer Token

Every CPX call requires a fresh bearer token.

POST {CPX_BASE}/auth/v1/authenticate
Body: { username, password }
Returns: { token }

1. Create Supplier

Triggered by: createVCForService_ensureSuppliercreateSupplierVirtualCard

Pre-check first — skip creation if supplier already exists:

GET {CPX_BASE}/payee/v1/payee?search={vcStableId}&size=5

If a record with sid === vcStableId is found → use existing, skip creation.

POST {CPX_BASE}/payee/v1/payee
Body:
{
  "payeeType": "Supplier",
  "status": "Approved",
  "isSupplierEnablement": false,
  "sid": "BundulInc-{userId}-{subId}",
  "externalStatus": "Active",
  "name": "John Doe (john@email.com)",
  "acceptsCard": true,
  "requiresLodged": true,
  "address": { "country": "USA" },
  "variance": { "amount": 0 },
  "paymentTypes": [{
    "type": "V Card",
    "id": "V Card",
    "deliveryMethod": "Email",
    "noNotification": true,
    "enabled": true,
    "email": "john@email.com"
  }],
  "addedRelationshipBuyerIds": [{
    "id": "{app.bundulId}",
    "sid": "BundulInc-{userId}-{subId}"
  }]
}
Returns: { "id": "{cpx-supplier-uuid}", "sid": "BundulInc-..." }

One supplier per (user, subscription). Never created again if sid already exists in CPX.


2. Fund the Card — Initial PIF

Triggered by: _submitPifcreateVirtualCardInitialPaymentcreateInitialPayment

POST {CPX_BASE}/payment/v1/pif
Body:
{
  "institutionId": "Bundul",
  "fileName": "singlepayment",
  "records": [{
    "transactionId": "pif-{shortUserId}-{shortSubId}-{randomSuffix}",
    "institutionId": "Bundul",
    "buyerId": "Bundul",
    "buyer": { "id": "{app.bundulId}" },
    "bankRoutingNumber": "{decrypted Passport routing number}",
    "bankAccountNumber": "{decrypted Passport account number}",
    "amount": "{sub.amount × 1.15}",
    "supplierId": "BundulInc-{userId}-{subId}",
    "supplierName": "John Doe (john@email.com)",
    "accountType": "LDG",
    "payee": {
      "id": "BundulInc-{userId}-{subId}",
      "name": "John Doe (john@email.com)",
      "address": { "city": "", "state": "", "postalcode": "" }
    },
    "fileDate": "{ISO timestamp}",
    "emailNotes": ""
  }]
}
Returns:
{
  "rec": [{
    "id": "{cpxCardIdForDetails}",
    "transactionId": "pif-...",
    "originalAmount": 17.19,
    "paymentStatus": "Pending",
    "accountType": "LDG",
    "supplier": {
      "id": "{cpx-supplier-uuid}",
      "sid": "BundulInc-{userId}-{subId}"
    }
  }]
}

rec[0].id is stored permanently as cpxCardIdForDetails. Every subsequent balance and details call uses this ID. The supplierId and payee.id must always be the vcStableId (sid) — never the CPX internal UUID.


3. Get Card Details

Triggered by: post-creation fetch, or admin GET /virtual-card-admin/cards/:vcStableId/info

GET {CPX_BASE}/payment/v1/decryptCardData/{cpxCardIdForDetails}
Returns:
{
  "virtualCardNumber": "4485250083342979",
  "securityCode": "176",
  "vcnStatus": "Success",
  "amount": 17.19,
  "expirationDate": "2030-04-30T16:00:00.000Z",
  "expirationString": "2030-04-30",
  "nameOnCard": "Bundul Inc."
}

4. Get Balance

Triggered by: admin GET /virtual-card-admin/cards/:vcStableId/balance

POST {CPX_BASE}/vcn/v1/vcnTransaction/balance
Body: { "id": "{cpxCardIdForDetails}" }
Returns:
{
  "balance": 51.41,
  "isAnyPendingAuth": "",
  "status_code": 0,
  "status": "Success"
}

5. Top-Up (Weekly Cron, Monthly Cron Disabled, + Manual)

Supplier already exists — no supplier creation. To RELOAD the existing lodge card (not mint a new one) the PIF must include accountId = cpxAccountId (the lodge-card id). Same sid alone is NOT enough. Passing the wrong id — cpxCardIdForDetails (the details/payment id) — is rejected by CPX with "No Active LDG Card found for the accountId …" (this was the long-standing top-up bug; fixed 2026-07-27).

Cron schedule: there are two top-up jobs, and only one of them runs.

Job Schedule State
VirtualCardTopupJob — top up every user's card EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT disabled, pending Good-Funds settlement of the first live top-up
VirtualCardScopedTopupJob — flat weekly top-up for a named test set 0 8 * * 1 (Mondays 08:00 Africa/Lagos, not UTC) active

The weekly job is the one that actually runs today. It tops up a fixed list of user ids (VC_TOPUP_TEST_USER_IDS, falling back to a built-in test set) by a flat amount (VC_WEEKLY_TOPUP_AMOUNT, default $10), rather than topping up everyone by their subscription price.

It takes a cluster-wide lock before doing anything. Both ECS tasks fire the same @Cron, so without the lock each would submit a duplicate PIF and risk minting a duplicate CPX card. If you add another scheduled job that moves money, do the same.

Code: src/jobs/jobs/virtual-card-scoped-topup.job.tsrunWeeklyFlatTopUp. Code path: VirtualCardTopupJobtopUpVirtualCardForAllUsers_topUpOneVcresolveCpxAccountIdcreateVirtualCardInitialPaymentPOST /payment/v1/pif

POST {CPX_BASE}/payment/v1/pif
Body: (same as Step 2, PLUS accountId)
  transactionId: new unique value every call
  supplierId:    "BundulInc-{userId}-{subId}"  ← same sid → same supplier
  accountId:     "{cpxAccountId}"              ← lodge-card id → reload THIS card
  amount:        {current sub.amount × 1.15}

transactionId must be unique per PIF call. supplierId stays the same (same supplier), and accountId = cpxAccountId is what tells CPX to load onto the existing lodge card instead of minting a new one.


6. Cancel a Card

Triggered by: admin POST /virtual-card-admin/cards/:vcStableId/cancel or user off-boarding

Step 6a — Look up the accountId

GET {CPX_BASE}/payment/v1/payment
  ?search={cpxTransactionId or vcStableId}
  &excludeDebits=true
  &excludeInstitutionCredits=true
  &excludeReturns=true
  &sort=importDate:desc
  &size=10
Returns: { "records": [{ "accountId": "{cpxAccountId}", ... }] }

Step 6b — Disable the lodge card

POST {CPX_BASE}/payment/v1/disableCard
Body:
{
  "buyerId": "{app.bundulId}",
  "cardId": "{cpxAccountId}",
  "type": "Lodged / Legacy"
}

CPX known quirk: returns HTTP 500 with body { statusCode: 413 } after successfully cancelling a lodge card. This is treated as success. All other errors are genuine failures.


Full Lifecycle

Subscription created
        │
        ▼
[SUPPLIER CHECK]  GET /payee/v1/payee?search={vcStableId}
        │                           │
    not found                    found
        │                           │
        ▼                           ▼
[CREATE SUPPLIER]            use existing supplier
POST /payee/v1/payee
        │
        ▼
[INITIAL PIF]  POST /payment/v1/pif
        │  stores cpxCardIdForDetails permanently
        ▼
[GET CARD DETAILS]  GET /payment/v1/decryptCardData/{cpxCardIdForDetails}
        │
        ▼
   Card is live — user uses it for the subscription
        │
        ▼  (1st of every month)
[TOP-UP PIF]  POST /payment/v1/pif
        │  same supplierId=vcStableId, new transactionId each time
        ▼
[CHECK BALANCE]  POST /vcn/v1/vcnTransaction/balance  (any time)
        │
        ▼  (user cancels or off-boards)
[LOOKUP accountId]  GET /payment/v1/payment?search={transactionId}
        │
        ▼
[DISABLE CARD]  POST /payment/v1/disableCard  { cardId: accountId }

Admin Endpoints Reference

POST /virtual-card-admin/weekly-topup triggers the weekly flat top-up by hand.

All routes under /virtual-card-admin require Basic auth (ADMIN_USERNAME / ADMIN_PASSWORD).
email or userId accepted wherever a user target is needed.

Method Path Purpose
GET /cards List all VCs, or filter ?email= / ?userId=
GET /cards/:vcStableId Single VC with full ledger history
GET /cards/:vcStableId/balance Live balance from CPX
GET /cards/:vcStableId/info Card details: VCN, expiry, CVV, limit
GET /cards/:vcStableId/cpx-record Raw CPX payment record (diagnostic)
GET /ledger?email= Full ledger for a user across all VCs
GET /passport-state?email= Passport + VC state diagnostic snapshot
GET /all-cards All lodge cards registered under Bundul in CPX
GET /events/:eventId Look up a stored CPX card event
POST /create { email/userId, subId } — create VC for a subscription
POST /top-up { email/userId } — top up all VCs for a user
POST /cards/:vcStableId/top-up-single Top up one specific card
POST /cards/:vcStableId/cancel Cancel a single VC
POST /cancel-all { email/userId } — cancel all VCs for a user
POST /reset-and-recreate { email/userId } — cancel all + recreate fresh
POST /cards/:vcStableId/fetch-vcn Pull vcnReference + accountId from CPX and store