Problem RestatementProblem
Design the card issuer's system (asked at Capital One) that supports:
- Application login (for the customer app),
- Payment authorization with fraud detection: when a card is swiped, approve or decline in real time,
- Credit limit decisions: don't approve beyond available credit,
- a view of spending for the user,
- Monthly credit bureau reporting (sending account data to Experian, Equifax and TransUnion).
How a Card Payment Works (simple version)
- The customer pays at a merchant. The merchant's bank (the acquirer) sends an authorization request through the card network (Visa/Mastercard) to the issuer (us).
- We must reply approve or decline within a strict time limit (the network gives about 1–2 seconds in total, and our budget is maybe ~100–200 ms).
- If approved, we place a hold on available credit. The transaction is pending.
- Later (usually within days), the merchant captures/settles. The transaction becomes posted and moves into the statement balance.
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"] --> GW["Authorization gateway - ISO 8583"]
GW --> AUTH["Authorization engine"]
AUTH --> ACC[("Account + available credit - in-memory, replicated")]
AUTH --> FR["Fraud scoring - model + rules"]
AUTH --> RULES["Card controls - lock, limits, MCC blocks"]
AUTH -->|"decision"| GW
AUTH --> K[("Auth events")]
K --> LED[("Ledger - pending holds")]
NET -->|"clearing files"| SET["Settlement processing"]
SET --> LED
LED --> APP["Customer app - spending view"]
LED --> BUR["Monthly bureau reporting job"]
BUR --> CB["Credit bureaus"]Deep Dive — Approving or declining in under 100 millisecondsDeep dive
The terminal is waiting and the network will time out. Whatever the answer is, it has to be produced within a budget measured in tens of milliseconds, and it has to be right about money.
Query the ledger, then run the full model
Read the account's posted transactions to compute the balance, then score the transaction with the complete fraud model.
%%{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 arrives"] --> SUM["Sum the ledger to compute the balance"]
SUM --> SLOW["Tens of ms, more for a busy account"]
SLOW --> ML["Full fraud model - feature fetches, model call"]
ML --> TO["Over budget - the network times out"]
TO --> RETRY["Network retries - a second authorisation for the same purchase"]Both steps are unbounded. And a timeout is not a neutral outcome: the network retries, so a slow decision becomes a duplicate authorisation on the same card.
Cache the balance
Keep the account's balance in a cache and read it on the authorisation path.
Fast enough, and now wrong in a way that costs money. Several authorisations for one account can arrive in the same instant — a card used at a pump and online — and each reads the same cached balance and approves. The account goes over its limit by the number of concurrent authorisations, and a cache has no way to prevent it.
Ordered checks, atomic account state, and a fraud budget
Run the cheap, decisive checks first and let the expensive one be interruptible:
%%{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
A["Authorisation"] --> D{"Duplicate request id?"}
D -->|"yes"| SAME["Return the same answer - networks retry"]
D -->|"no"| S{"Card active, not lost, expiry / CVV / PIN ok?"}
S -->|"no"| DECL["Decline"]
S -->|"yes"| C{"User controls - merchant category, international, per-txn limit"}
C -->|"blocked"| DECL
C -->|"ok"| CAS["Compare-and-set: limit - (posted + holds) >= amount"]
CAS -->|"fails"| DECL2["Decline - insufficient credit"]
CAS -->|"succeeds"| F["Fraud score - with a time budget"]
F -->|"in time, high risk"| DECL3["Decline, release the hold"]
F -->|"in time, ok"| APPR["Approve"]
F -->|"budget exceeded"| DEF["Default action - approve and flag for review"]- Deduplicate first. Networks retry, so the same request id must return the same answer rather than placing a second hold. This is the cheapest check and it prevents the most expensive failure.
- Keep account state in a fast, strongly consistent store and update it with a compare-and-set that includes pending holds. Concurrent authorisations then serialise on that one operation, so the limit cannot be exceeded by parallelism.
- Give fraud scoring a time budget and a defined default. The model is the only step whose latency you do not fully control, so decide in advance what happens when it does not answer — usually approve and flag for review, because declining a good customer costs more than a rare bad transaction.
Order the checks cheapest-first: most declines are settled before anything touches the expensive path.
Pending vs Posted and the Ledger
- Approved auths create pending holds in the ledger. Settlement files from the network turn them into posted transactions (sometimes with a different final amount, e.g., a tip), and release the holds.
- Holds that never settle expire after N days.
- The customer app shows both: "Pending: $54.20 at Coffee Co." and posted history.
Monthly Credit Bureau Reporting
- A batch job at each statement cycle builds the standard report format (Metro 2 in the US) for every account: balance, credit limit, payment status, days past due.
- It must be accurate and auditable: use the ledger's statement snapshots, validate the file, keep copies, and have a disputes process to correct errors.
Reliability
- Active-active across regions: account state replicated synchronously within a region and carefully across regions (or each account homed to a region with failover).
- Stand-in processing: if our system is unreachable, the network can approve small transactions on our behalf using pre-agreed rules. We reconcile them afterwards.
- Idempotency everywhere (retries from networks are normal).
Wrap-UpWrap-up
Receive authorization requests from the network, deduplicate them, and run fail-fast checks in memory (card status, user controls, available credit including pending holds, fraud score with rules fallback) within a tight latency budget, then place a hold and respond. Record holds in a ledger that settlement files turn into posted transactions, show pending vs posted to customers, generate audited monthly credit bureau reports from statement snapshots, and rely on active-active deployment plus network stand-in for availability.