Problem RestatementProblem
A virtual card is a separate card number (with its own expiry and CVV) linked to a customer's real credit card account. Customers create them for online shopping: e.g., single-use cards, cards locked to one merchant (only Netflix can charge it), or cards with a spending limit. If a merchant is hacked, only the virtual number leaks, and the customer can close it without replacing their real card.
Capital One asked two parts: (1) product reasoning, meaning benefits and drawbacks for users and the bank, and (2) transaction validation: when a charge arrives with an encoded virtual card ID, decide approve or decline in real time. A related question added geography-aware rules.
Product Reasoning (say this briefly)
- For users: safer online shopping, easy cancellation, spending control, and fewer real-card reissues.
- Drawbacks for users: extra steps, and problems with merchants that need the physical card at pickup (hotels, rentals) or recurring charges tied to a single-use card.
- For the bank: lower fraud losses and reissue costs, more engagement, and data on merchant subscriptions.
- Costs for the bank: more complex authorization, more card numbers to manage, and support calls when a locked card is declined.
RequirementsRequirements
- Create virtual cards with rules: single-use / multi-use, merchant lock (set on first use or chosen up front), spending limit (per transaction or monthly), expiry, and allowed countries.
- Pause, resume or close a card.
- Authorize each transaction in real time (under ~100 ms of our budget inside the card network's time limit).
- Every decision is logged and explainable.
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
NET["Card network - auth request"] --> AUTH["Authorization Service"]
AUTH --> VAULT["Token vault - decode virtual PAN"]
AUTH --> VC[("Virtual cards + rules - cached")]
AUTH --> ACC["Core account - available credit"]
AUTH --> FR["Fraud scoring"]
AUTH -->|"approve / decline + reason"| NET
AUTH --> LOG[("Decision log")]
APP["Customer app"] --> MGMT["Card Management API"]
MGMT --> VC
MGMT --> VAULT- Token vault: maps the virtual card number (PAN) to
virtual_card_idand the real account. Card numbers live only here (a small PCI scope). The encoded ID in the request is decoded or looked up here. - Virtual card rules are cached in memory in the authorization service for speed, and invalidated on change.
- Core account service: the real available credit, with holds placed on approval.
Validation Rules (in order, fail fast)
- Decode and look up the card. Unknown → decline "invalid card".
- Status: closed or paused → decline.
- Expiry and CVV match.
- Single-use: already used → decline.
- Merchant lock: if locked, the merchant ID must match. If "lock on first use" and not yet locked, lock it to this merchant atomically (so two first charges can't both lock).
- Limits: amount ≤ per-transaction limit, and monthly spent + amount ≤ monthly limit.
- Geography: the merchant's country is in the allowed list (and optionally matches the customer's recent location or travel notice).
- Real account: available credit ≥ amount → place a hold.
- Fraud score below a threshold, or step-up (send the customer a confirmation).
- Approve: record the decision, update counters (spent, used flag), and return the approval code.
Each decline returns a specific reason code for the network and the customer's app ("Declined: card locked to another merchant").
Deep Dive — Enforcing a virtual card's limits at authorisation timeDeep dive
A single-use card is presented, or a card locked to one merchant, or one with a $200 cap. The network wants an answer in well under a second, and the answer has to be right the first time.
Check the limit, then forward the authorisation
Read the card's rules and remaining balance, decide it is fine, pass the authorisation to the real account.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant M1 as Merchant A
participant V as Virtual card service
participant M2 as Merchant B
M1->>V: authorise $150 - card limit $200
V->>V: read remaining = 200, ok
M2->>V: authorise $150 - same card
V->>V: read remaining = 200, ok
V->>V: both forwarded - $300 on a $200 cardThe check and the spend are separate, so two authorisations arriving together both pass. A single-use card is worse: both charges succeed on a card that was supposed to work exactly once.
Decrement conditionally as part of the decision
Make it one statement: UPDATE cards SET remaining = remaining - :amt WHERE card_id = :id AND remaining >= :amt. Zero rows means decline.
The race is gone and the arithmetic is right. What is wrong is the model: an authorisation is not a charge. The merchant may capture less than they authorised, capture days later, or never capture at all — a hotel holds $200 and charges $140. Decrementing at authorisation permanently consumes money that was never spent, and the customer's limit is wrong until somebody notices.
Hold at authorisation, settle at capture, release on expiry
Model the two events separately, which is what the card networks actually do:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
AUTH["Authorisation request"] --> RULES{"Merchant lock, amount cap, card active?"}
RULES -->|"fails"| DECL["Decline - reason recorded"]
RULES -->|"passes"| HOLD["Place a hold - atomic, counts against the limit"]
HOLD --> FWD["Forward to the real account"]
HOLD --> SU{"Single-use card?"}
SU -->|"yes"| CLOSE["Close the card immediately - no second authorisation"]
FWD --> CAP["Capture arrives - settle, convert the hold to a charge"]
FWD --> REV["Reversal or expiry - release the hold"]
CAP --> LESS["Captured less than held? Release the difference"]- Holds are atomic and count against the limit, so concurrent authorisations cannot both pass — but they are provisional, so unspent money comes back.
- Expire holds on a timer. Authorisations that are never captured must release, or the card slowly fills with phantom spend. This is the sweeper the previous rung had no need for and no place to put.
- A single-use card closes at the first successful authorisation, not at capture. Waiting for capture leaves a window in which a second authorisation is still valid, which is exactly what the card exists to prevent.
Run the validation rules in cheapest-first order and fail fast: card active, merchant matches the lock, amount within the per-transaction cap, then the balance check that requires a write. Most declines are decided before touching the expensive path.
Concurrency and Reliability
- Two charges at the same time on a single-use card: use a conditional update (
SET used = true WHERE id = ? AND used = false). Only one succeeds. - Monthly spend counters are updated atomically alongside the hold.
- Idempotency: networks retry authorization messages, so decisions are keyed by the network's transaction ID, and a retry returns the same answer.
- High availability: multi-region active-active authorization with replicated card rules. If the core account system is slow, card networks allow stand-in decisions with conservative limits.
Trade-offs & AlternativesTrade-offs
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Card data | Separate vault (tokenization) | Small PCI scope, safer | Store PANs everywhere: huge compliance risk |
| Rule checks | In-memory cached rules, fail fast | Meets network latency | DB queries per check: slow |
| First-use lock | Atomic conditional update | No double locks | Read-then-write: race |
| Retries | Idempotent by network transaction ID | Same answer on retry | New decision each time: double holds |
Wrap-UpWrap-up
Virtual cards give customers safer, controllable card numbers linked to one real account, with lower fraud costs for the bank. Authorize each transaction by decoding the card through a token vault, then checking cached rules in a fail-fast order: status, expiry, single-use, merchant lock, limits, geography, available credit and fraud. Use atomic conditional updates for single-use and first-use locks, idempotency by network transaction ID, and explainable decline reasons.