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 → _ensureSupplier → createSupplierVirtualCard
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
sidalready exists in CPX.
2. Fund the Card — Initial PIF
Triggered by: _submitPif → createVirtualCardInitialPayment → createInitialPayment
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].idis stored permanently ascpxCardIdForDetails. Every subsequent balance and details call uses this ID. ThesupplierIdandpayee.idmust always be thevcStableId(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.ts → runWeeklyFlatTopUp.
Code path: VirtualCardTopupJob → topUpVirtualCardForAllUsers → _topUpOneVc → resolveCpxAccountId → createVirtualCardInitialPayment → POST /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}
transactionIdmust be unique per PIF call.supplierIdstays the same (same supplier), andaccountId = cpxAccountIdis 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 |