| holds table | +1000000 | kind card_auth, expires in 7 days |
| ledger effect | none | available balance drops; ledger balance unchanged |
Round eight: “issue cards”
Every transaction so far started with our customer, in our app, on our terms. A card inverts that: a terminal in a shop somewhere sends us a debit request, and we have under two seconds to answer, whether or not our systems are healthy. This round is about designing for a latency budget we do not own and a failure mode where saying nothing is worse than saying no.
The pressure: someone else initiates the debit
“We are issuing debit cards on Verve and Mastercard. A terminal in a supermarket sends an authorisation request. You have two seconds. What happens, and what happens if your ledger is unreachable?”
Three things are genuinely new, and naming them establishes why this round needs different machinery.
- We are no longer the initiator. The request arrives from outside, on the network's schedule, at the network's volume.
- The budget is not ours. The scheme imposes a timeout, typically 2 seconds end to end, of which our share is smaller still. Exceed it and the network answers on our behalf.
- Silence is the worst answer. A decline is a bad experience. A timeout means the scheme decides using rules we configured weeks ago, and we find out later.
- Authorise or decline a card transaction within the network's budget.
- Place an auth hold at authorisation; post entries only at capture.
- Handle partial capture, over-capture within tolerance, and expiry.
- Process daily clearing and settlement files and reconcile them.
- Support chargebacks through a defined dispute lifecycle.
- Continue authorising when the core ledger is unreachable.
- Authorisation p99 under 500 ms inside our boundary, against a 2-second scheme budget.
- Authorisation availability 99.99%, higher than any other path in the bank.
- No card data in our primary systems. PCI scope minimised by design.
- A capture must post exactly once even though clearing files are redelivered.
The four-party model
Knowing the participants and where the money and the fees flow is the vocabulary for everything after.
| Party | Role | Us? |
|---|---|---|
| Cardholder | Our customer, holding the card | Our customer |
| Issuer | Issued the card, holds the funds, makes the authorisation decision | This is us |
| Acquirer | The merchant's bank, submits transactions into the scheme | Someone else |
| Merchant | Sells the goods, receives settlement from the acquirer | Someone else's customer |
| Scheme | Verve, Mastercard, Visa. Routes messages, sets rules, performs settlement | Our counterparty |
Where the money goes, and where it stops
customer pays ₦10,000 at a merchant
merchant receives ₦9,800 (after acquirer's merchant discount)
acquirer keeps ₦50
scheme keeps ₦30 (scheme fee)
we receive ₦120 (interchange, paid TO the issuer)
interchange is revenue for us: the issuer is paid for taking
the credit and fraud risk on the transaction.
so our ledger shows: customer −10,000, and later +120 income
The three message flows
| Flow | When | Timing | Money moves? |
|---|---|---|---|
| Authorisation | At the terminal, in real time | Under 2 seconds | No. A hold is placed |
| Clearing | Batch, usually overnight | Hours to days later | Yes. Entries are posted |
| Settlement | Net position with the scheme | Daily | Yes, in aggregate between institutions |
The separation of authorisation from clearing is the single most important structural fact about cards, and it maps exactly onto the holds model built in Part 5. That is not a coincidence; holds exist because of flows like this.
Authorisation: the 2-second budget
Budget the time explicitly, because every check competes for the same milliseconds and the ordering of the checks is a design decision.
Inside our 1,000 ms, spent in this order:
- Cheapest and most decisive first. A blocked card is rejected in 8 ms without troubling the ledger or the fraud model.
- Fraud before the hold, so a fraudulent transaction never touches the money path.
- The hold last, because it is the only step that writes, and writes are the expensive, contended operation.
- Every step has its own timeout that fits the remaining budget, and the budget is passed down as a deadline rather than assumed.
// deadline propagation: each step gets what is actually left, not a
// fixed timeout that could collectively exceed the budget.
async function authorise(msg: AuthMessage): Promise<AuthResponse> {
const deadline = Date.now() + 1000;
const left = () => deadline - Date.now();
const card = await cards.lookup(msg.token, { timeout: Math.min(50, left()) });
if (!card || card.state !== 'active') return decline('57');
const vel = await limits.check(card, msg.amount, { timeout: Math.min(50, left()) });
if (!vel.ok) return decline('61');
// fraud FAILS OPEN. an unavailable model must not decline good business;
// the asynchronous path in Part 9 catches what the fast path misses.
const risk = await fraud.score(msg, { timeout: Math.min(150, left()) })
.catch(() => ({ action: 'allow', degraded: true }));
if (risk.action === 'block') return decline('59');
// the hold FAILS CLOSED via stand-in: see the next chapter.
const hold = await ledger.placeHold(card.accountId, msg.amount, {
timeout: Math.min(250, left())
}).catch(() => standIn.decide(card, msg));
return hold.approved ? approve(hold.id) : decline('51');
}Stand-in processing and the offline window
The resolution of the tension from chapter 88. The ledger is CP by design and authorisation needs four nines. So authorisation stops depending on the ledger for every decision.
- A balance snapshot per card account is replicated continuously to the authorisation tier, seconds behind the ledger.
- When the ledger is unreachable, the authoriser decides from the snapshot plus a conservative cap.
- Every stand-in approval is durably recorded locally before the response is sent.
- On recovery, the records are replayed to the ledger as holds, in order.
- The worst-case exposure is bounded and calculated in advance, not discovered afterwards.
The stand-in rules, and their arithmetic
interface StandInPolicy {
maxPerTransaction: bigint; // e.g. ₦20,000
maxPerCardDuringOutage: bigint; // e.g. ₦50,000
maxTransactionsPerCard: number; // e.g. 5
requireSnapshotAge: number; // decline if snapshot older than 300 s
// merchant categories we refuse to stand in for: unrecoverable
// or high-risk. cash, crypto, gambling.
blockedMcc: string[];
} worst case exposure during a 15-minute outage:
active cards likely to transact in 15 min ≈ 40,000
× max per card during outage = ₦50,000
= theoretical maximum ₦2bn
realistic: only accounts with insufficient funds create loss,
historically ~0.3% of stand-in approvals
→ expected exposure ≈ ₦6m, against the cost of
declining every card transaction for 15 minutes
the tradeoff is quantified, and that is what makes it defensibleAuth holds, partial capture, and expiry
The holds machinery from Part 5, now driven by a network rather than by us. Three cases, all routine, all needing explicit handling.
The normal case: hold then capture
| wallet A | −1000000 | now the money actually moves |
| card_settlement:mastercard | +1000000 | owed to the scheme at settlement |
| Σ | 0 | hold marked captured, linked to this journal id |
Partial and over capture
| Case | Example | Handling |
|---|---|---|
| Partial capture | Held ₦10,000, one item out of stock, captured ₦7,500 | Post ₦7,500. Release the remaining ₦2,500 of hold immediately rather than waiting for expiry. |
| Multiple captures | Held ₦10,000, shipped in two parcels: ₦6,000 then ₦4,000 | Two journals against one hold. The hold closes when fully consumed. |
| Over capture within tolerance | Held ₦10,000 at a fuel pump, captured ₦10,800 | Scheme rules permit this for specific MCCs, typically up to 15 to 20%. Post the captured amount, and note it may push the account negative. |
| Over capture beyond tolerance | Held ₦10,000, captured ₦25,000 | Post it anyway, and flag for dispute. Refusing a valid scheme-presented clearing record is not an option; recovery is via chargeback. |
| Expiry | Held ₦10,000, never captured | Sweeper releases at the scheme's expiry window: 7 days typical, up to 30 for hotels and car rental. |
Clearing and settlement files
Authorisation is real-time messaging. Clearing is batch file processing, and it is the authoritative record of what the scheme believes happened.
- The scheme publishes a clearing file: every transaction presented against our cards.
- We match each record to an existing authorisation by the network reference.
- Matched records are captured: entries posted, hold consumed.
- Unmatched records are the interesting ones, and each needs a defined path.
- A settlement file states the net amount we owe the scheme or it owes us.
- One settlement payment moves through a rail from Part 6, and reconciliation in Part 10 proves it.
| Unmatched case | Cause | Handling |
|---|---|---|
| Clearing with no authorisation | Offline terminal, forced acceptance, or a stand-in approval the scheme made for us | Post it. The scheme's record governs. May overdraw the account, which is a recovery problem rather than a posting one. |
| Authorisation with no clearing | Merchant never completed the sale | Let the hold expire. Normal and common. |
| Amount mismatch | Partial, over-capture, or tip added | Post the cleared amount; reconcile the difference against the hold. |
| Duplicate clearing record | Scheme file redelivered or reprocessed | Idempotency on the network reference. This is the mechanism that makes file reprocessing safe. |
-- the idempotency key that makes clearing-file reprocessing safe.
-- schemes DO redeliver files, and operators DO re-run failed batches.
INSERT INTO journal (id, kind, idempotency_key)
VALUES ($jid, 'card.capture',
'clr:' || $schemeRef || ':' || $clearingDate);
-- a redelivered file hits the unique constraint and posts nothing.
-- same pattern as the accrual batch in Part 5: make the key carry
-- enough identity that a re-run is provably a no-op. settlement is net, not gross:
purchases by our cardholders −₦840m we owe
refunds to our cardholders +₦31m
interchange earned +₦10m revenue
scheme fees −₦2m
net owed to scheme ₦801m
one payment settles millions of transactions, which is why the
card_settlement account balance must match the file exactly.Debit against a wallet vs credit against a line
The same authorisation path serves both card types. What differs is which account is checked and which is debited, and the answer reuses Part 5 entirely.
| Debit card | Credit card | |
|---|---|---|
| Funds source | The customer's own wallet | A credit line, which is the bank's money |
| Authorisation check | available >= amount | credit_limit − utilised >= amount |
| Account debited at capture | Customer wallet (a liability of the bank) | Card receivable (an asset of the bank) |
| Interest | None | Accrues after a grace period, on revolved balances |
| Statement cycle | None; each transaction is final | Monthly, with a minimum payment due |
| Bank's risk | Fraud only | Fraud plus credit risk |
| Interchange earned | Lower | Higher, reflecting the credit risk taken |
| card_receivable:C-4471 | −1000000 | the customer now owes us this |
| card_settlement:mastercard | +1000000 | we owe the scheme at settlement |
| Σ | 0 | note the customer's wallet is not touched at all |
overdraft_facilities table, with its limit and
its allowed-kinds gate, already models the authorisation logic. The genuinely new
parts are the statement cycle and the grace period, both of which are
scheduled batch jobs of exactly the shape built in chapter 60. Recognising that
a new product is an existing product plus a schedule is how you avoid building a
second system.Multi-currency cards and DCC
A Nigerian card used in London. Three currencies are in play and a decision about who converts, which is both a product and a fairness question.
transaction currency GBP what the merchant charges
settlement currency USD what the scheme settles in
billing currency NGN what we debit the customer in
two conversions, two spreads, two chances to be unfair- Multi-currency wallet. If the customer holds GBP, debit GBP directly. No conversion, no spread. Best for the customer and the reason multi-currency wallets are attractive.
- Issuer conversion. The scheme converts GBP to USD at its rate, we convert USD to NGN at ours. Two spreads, ours disclosed on the statement.
- DCC, dynamic currency conversion. The merchant's terminal offers to charge in NGN directly. Usually the worst rate of the three, because the merchant and acquirer share that margin.
// selecting the funding account: prefer a matching currency wallet,
// which avoids conversion entirely. this is a real customer benefit and
// it costs us the FX spread we would otherwise have earned, so it is a
// deliberate product choice rather than an oversight.
function selectFunding(card: Card, txCcy: string): Account {
const match = card.wallets.find(w => w.currency === txCcy && w.available > 0n);
if (match) return match; // no conversion: cheapest for them
return card.wallets.find(w => w.currency === card.billingCurrency)!;
}market_rate and customer_rate on
every conversion, as chapter 83 did, is what makes that statement line possible.
A design decision in round seven pays off as a compliance capability in round
eight, which is worth pointing out because it demonstrates the coherence of the
whole design.Chargebacks and the dispute ledger
A cardholder disputes a transaction. What follows is a multi-stage process with deadlines, evidence and money moving back and forth, and it must all be on the ledger.
1. dispute raised customer says "not mine"
↓ we may provisionally credit them
2. chargeback we claim against the acquirer
↓
3. representment merchant provides evidence and pushes back
↓
4. pre-arbitration one more exchange
↓
5. arbitration the scheme decides, and charges a fee to the loser
each stage has a hard deadline. missing one forfeits the case
automatically,
which makes the deadline timer a correctness feature, not a reminder.
| dispute_suspense | −1000000 | the bank funds the customer meanwhile |
| wallet A | +1000000 | customer made whole during investigation |
| Σ | 0 | if we lose, the suspense clears to a loss account instead |
CREATE TABLE disputes ( id UUID PRIMARY KEY, journal_id UUID NOT NULL, -- the disputed capture reason_code TEXT NOT NULL, -- scheme-defined, drives evidence needed stage TEXT NOT NULL, -- raised|chargeback|representment|arbitration amount BIGINT NOT NULL, currency CHAR(3) NOT NULL, -- the single most operationally important column in the table. -- a missed deadline is an automatic loss, so this drives alerting. stage_deadline TIMESTAMPTZ NOT NULL, provisional_credit_journal UUID, outcome TEXT, -- won | lost | withdrawn created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX disputes_due ON disputes (stage_deadline) WHERE outcome IS NULL; -- the queue that must never be starved
PCI DSS scope, tokenisation, and the HSM
Card data is radioactive. The correct architectural goal is not to protect it well but to never hold it, so that most of the compliance burden does not apply to most of the system.
| Data | May we store it? | Notes |
|---|---|---|
| PAN, the 16-digit card number | Only encrypted, in PCI scope | Avoid entirely. Store a token instead |
| Cardholder name, expiry | Yes, with protection | In scope when stored alongside a PAN |
| CVV / CVC | Never, under any circumstances | Not even encrypted. Prohibited after authorisation |
| Full magnetic stripe or chip data | Never | Prohibited after authorisation |
| PIN or PIN block | Never outside an HSM | PIN operations happen inside hardware only |
| Token, plus last 4 and BIN | Yes, freely | Sufficient for display, routing and support |
The architecture that shrinks scope
in PCI scope (small, isolated, separately audited):
the card vault, the HSM, the authorisation message gateway
out of scope (everything else, which is the point):
the ledger, product services, notifications, analytics,
support tooling, the mobile app backend
the ledger stores a token and never a PAN, so the most
critical system in the bank sits outside PCI scope entirely.- PIN verification without the PIN ever existing in software memory.
- Cryptogram validation for chip transactions, proving the card is genuine.
- Key management under keys that are physically non-exportable.
- Tamper response: physical intrusion causes immediate key destruction.
- The guarantee is that a full compromise of our servers does not yield the keys, because the keys never existed outside the hardware boundary. That is a property software cannot provide at any price.
Sketch v8: card authorisation on the money path
What changed, and the cost accepted
| Change | Driven by | Cost accepted |
|---|---|---|
| A separate authorisation tier | 99.99% target against a CP ledger | A second decision path to keep consistent with the first |
| Stand-in processing | Must authorise when the ledger is unreachable | A bounded, quantified overdraw exposure |
| Deadline propagation | A 2-second budget we do not own | Every dependency needs a timeout that fits the remainder |
| Fraud fails open, balance fails to stand-in | Different costs of being wrong | Must be chosen and documented per dependency |
| Clearing file pipeline | Capture is batch, not real time | Unmatched-record handling, and idempotency on the scheme reference |
| Dispute subsystem with deadlines | Chargeback rights and scheme rules | A deadline queue that must be alerted on, or money is silently lost |
| Isolated card vault and HSM | PCI DSS | Separate infrastructure and audit, in exchange for keeping everything else out of scope |