| wallet A (NGN) | NGN | −80000000 | customer gives up naira |
| fx_position:NGN | NGN | +80000000 | bank now holds the naira |
| Σ NGN | 0 | naira balances independently ✓ | |
| fx_position:USD | USD | −50000 | bank gives up dollars |
| wallet A (USD) | USD | +50000 | customer receives $500.00 |
| Σ USD | 0 | dollars balance independently ✓ | |
| invariant | per currency | both legs zero. no cross-currency arithmetic in the ledger at all |
Round seven: “multi-currency, multi-jurisdiction”
The "single currency for now" from round one comes due. Adding currencies is not adding a column, because money in different currencies is not comparable, and an invariant that summed globally must now sum per currency. Then jurisdiction arrives, which is harder still: not a technical constraint but a legal one that decides where bytes may physically live.
The pressure: one ledger, many moneys
“Customers now hold NGN, USD, EUR and GBP. They convert between them. We operate in Nigeria, the UK and the EU, and each regulator has opinions about where data lives. What breaks?”
What breaks is the assumption buried in every balance calculation so far, which is that amounts can be added together. They cannot. ₦500 plus $500 is not 1,000 of anything.
- The invariant.
SUM(amount) = 0across a journal becomes meaningless when the entries are in different units. - Balance queries. A missing
WHERE currency = ...returns a number that looks plausible and is nonsense. - Limit checks. "Daily limit 1,000,000" in which currency?
- Rounding. Two decimal places is wrong for JPY, KWD, and about twenty others.
- Reconciliation. A trial balance must balance per currency, and one that nets across currencies hides real breaks.
- A customer holds separate balances per currency, each independently auditable.
- A customer can convert between currencies at a quoted rate.
- The bank tracks its own FX position and earns the spread as revenue.
- Customer data lives in the jurisdiction that requires it.
- Compliance rules apply per jurisdiction, without forking the codebase.
- The invariant holds per currency, per journal, and is checked as such.
- No arithmetic ever combines amounts of different currencies without an explicit, recorded rate.
- A stale FX rate cannot be used to price a conversion.
- Cross-region reads must not violate residency rules, even accidentally.
Currency as a dimension, not a column
The distinction sounds like vocabulary and is structural. A column is data you filter by when you remember to. A dimension is part of the identity of the thing, so forgetting it is impossible rather than merely unwise.
| Approach | Model | Problem |
|---|---|---|
| Currency as a column on the entry | One account holds many currencies; each entry states its own | A balance query without a currency filter returns garbage. Nothing in the schema prevents it, and the bug is invisible in review. |
| One account per currency | (owner, currency) identifies an account. Currency is on the account, and entries inherit it | Chosen. A balance is per account, so it is per currency by construction. A cross-currency sum becomes impossible to write by accident. |
| A separate ledger per currency | Fully isolated schemas or databases | Strongest isolation, and it makes an FX conversion a cross-database saga for no real benefit over the middle option. |
-- currency lives on the ACCOUNT. an entry cannot disagree with it. CREATE TABLE accounts ( id UUID PRIMARY KEY, owner_id UUID, kind TEXT NOT NULL, currency CHAR(3) NOT NULL REFERENCES currencies(code), -- one wallet per owner per currency. the pair IS the identity. UNIQUE (owner_id, kind, currency) ); -- the entry still carries currency, and the database ENFORCES that it -- matches the account. belt and braces, because this is the invariant -- that protects every other one. ALTER TABLE entries ADD CONSTRAINT entry_currency_matches_account CHECK (TRUE) -- enforced by trigger or by a composite FK:; ALTER TABLE entries ADD FOREIGN KEY (account_id, currency) REFERENCES accounts (id, currency); -- requires UNIQUE(id, currency)
REVOKE UPDATE, DELETE in Part 2.Minor units, exponents, and JPY vs KWD
Round one stored money as integer minor units and flagged that the exponent is not always 2. Here is where that matters.
| Currency | Exponent | Minor unit | 100.00 displayed stores as |
|---|---|---|---|
| NGN Nigerian naira | 2 | kobo | 10000 |
| USD US dollar | 2 | cent | 10000 |
| JPY Japanese yen | 0 | yen | 100 |
| KWD Kuwaiti dinar | 3 | fils | 100000 |
| BHD, OMR, TND | 3 | fils / millime | 100000 |
| CLF Chilean unit of account | 4 | n/a | 1000000 |
CREATE TABLE currencies ( code CHAR(3) PRIMARY KEY, -- ISO 4217 exponent SMALLINT NOT NULL, -- 0, 2, 3, 4. NEVER assumed. name TEXT NOT NULL, -- some currencies are quoted per 100 units by convention (JPY pairs). -- display concerns belong here too, not scattered in the frontend. symbol TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE );
// formatting reads the exponent. it never divides by 100.
function format(minor: bigint, ccy: Currency): string {
if (ccy.exponent === 0) return minor.toString();
const divisor = 10n ** BigInt(ccy.exponent);
const whole = minor / divisor;
const frac = (minor < 0n ? -minor : minor) % divisor;
return `${whole}.${frac.toString().padStart(ccy.exponent, '0')}`;
}
// and parsing is the inverse, also driven by the exponent. a user typing
// "100" in JPY means 100 minor units; in KWD it means 100000.1000000 instead of 10000, making the payment 100 times
too large. It will also display ₩ and JPY amounts with phantom decimal places
that no customer recognises. Both are real, shipped bugs, and both are prevented by
one column read at the point of formatting.The zero-sum invariant, restated per currency
The most important correction in this part. The check from chapter 16 is now wrong, and wrong in a way that would hide real errors.
OLD, now incorrect:
SELECT journal_id, SUM(amount) FROM entries
GROUP BY journal_id HAVING SUM(amount) <> 0
a journal with −50000 NGN and +50000 USD passes this check.
it sums to zero and it is catastrophically unbalanced.
NEW, correct:
GROUP BY journal_id, currency HAVING SUM(amount) <> 0
-- the invariant, correctly stated. must return zero rows, forever.
SELECT journal_id, currency, SUM(amount) AS drift
FROM entries
GROUP BY journal_id, currency
HAVING SUM(amount) <> 0;
-- and the in-transaction assertion updated to match
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM entries
WHERE journal_id = $jid
GROUP BY currency
HAVING SUM(amount) <> 0
) THEN RAISE EXCEPTION 'ledger imbalance per currency on %', $jid;
END IF;
END $$;SUM(amount) = 0 per journal has to become per journal per
currency, otherwise a journal with a naira debit and a dollar credit passes
a check it should fail." Volunteering a correction to your own earlier work is
one of the strongest signals available in an interview, because it shows you
are tracking the design's consequences rather than defending your first answer.What this means for an FX conversion
A conversion has a naira leg and a dollar leg, and neither sums to zero on its own against the customer. So a conversion cannot be two entries. It must be four, and the extra two are the bank's own position, which is the subject of the next chapter.
FX conversion as two coupled postings
A customer converts ₦800,000 into USD at a rate of 1,600. The design question is how to express that in a ledger where each currency must balance independently.
Where the rate actually lives
Notice that no entry contains the rate. The entries are two independent, balanced facts. The rate is metadata on the journal, recorded for audit but not participating in any arithmetic the invariant checks:
CREATE TABLE fx_conversions ( journal_id UUID PRIMARY KEY REFERENCES journal(id), from_currency CHAR(3) NOT NULL, from_amount BIGINT NOT NULL, to_currency CHAR(3) NOT NULL, to_amount BIGINT NOT NULL, -- the rate as an exact decimal, never a float. and BOTH rates: -- what we charged the customer, and what the market was. customer_rate NUMERIC(20,10) NOT NULL, market_rate NUMERIC(20,10) NOT NULL, quote_id UUID NOT NULL, -- which quote authorised this quoted_at TIMESTAMPTZ NOT NULL );
customer_rate and market_rate is
the spread, which is the bank's revenue on this conversion. Storing both
makes FX revenue a query rather than a reconstruction, lets you answer "were we
competitive on this corridor last month", and satisfies the regulatory expectation
in several jurisdictions that the margin charged be demonstrable per transaction.Quote, spread, and the treasury position
The bank is taking the other side of every conversion, which means it is carrying risk. Three mechanisms manage that.
One: the quote, which must expire
CREATE TABLE fx_quotes ( id UUID PRIMARY KEY, pair TEXT NOT NULL, -- 'NGN/USD' customer_rate NUMERIC(20,10) NOT NULL, market_rate NUMERIC(20,10) NOT NULL, max_amount BIGINT NOT NULL, -- a quote is size-bounded customer_id UUID, -- quotes are not transferable -- short. 30 to 120 seconds. an expired quote CANNOT be executed. expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );
- Expiry bounds market risk. If the naira moves 3% and a customer executes a five-minute-old quote, the bank eats the difference.
- Size cap prevents a retail quote being used for an institutional trade the spread was never priced for.
- Customer binding stops a favourable quote being shared or resold.
- Both rates stored so the spread earned is known at execution, not reconstructed later.
-- execution atomically consumes the quote, so a quote cannot be used twice. -- this is the Part 2 conditional write, applied to a quote instead of a balance. UPDATE fx_quotes SET consumed_at = now() WHERE id = $quoteId AND consumed_at IS NULL AND expires_at > now() AND customer_id = $customer AND max_amount >= $amount; -- 0 rows ⇒ expired, already used, wrong customer, or too large. reject.
Two: the position, which is the bank's exposure
fx_position:USD balance = net dollars the bank is long or short
customers buy $500 → position:USD goes −50000 (short dollars)
customers sell $300 → position:USD goes +30000
net position = −20000 = short $200
a short USD position loses money when USD strengthens
so treasury hedges: buy $200 in the market to flatten itThe position account balance is the exposure, live, queryable, and derived from the same entries as everything else. No separate position-keeping system, no reconciliation between the ledger and a risk system, because they are the same data.
Three: the spread, booked as revenue
| fx_position:NGN | NGN | −400000 | ₦4,000 of the naira received is margin |
| fx_income:spread | NGN | +400000 | bank revenue, in naira |
| Σ NGN | 0 | revenue recognised in the currency it was earned in |
Data residency and jurisdictional partitioning
The shift here is that this is not an engineering optimisation. It is a legal constraint, and the penalty for getting it wrong is a regulator rather than a latency graph.
| Jurisdiction | Requirement | Architectural consequence |
|---|---|---|
| Nigeria (CBN, NDPA) | Customer and transaction data held within Nigeria; regulator access on demand | A Nigerian region that is complete, not a cache of somewhere else |
| EU (GDPR) | Transfers outside the EEA need a legal basis; strong subject rights including erasure | An EU region, plus a documented answer to erasure that does not break the ledger |
| UK (FCA, UK GDPR) | Similar to the EU, with safeguarding rules for client money | Segregated client-money accounts, reportable separately |
| US (state and federal) | Varies; BSA/AML reporting obligations | Reporting pipelines per obligation rather than one global report |
The partitioning model: region outside, shard inside
region = ng | eu | uk | us ← legal boundary, immovable
↓
logical shard = hash(account_id) % 4096 ← performance, inside a region
so an account's home is: (region, logical_shard)
a customer's region never changes. it is assigned at onboarding
from their residency and cannot be migrated without a legal process.
This layering is why Part 3 chose account-based sharding rather than region-based. Region is the outer, legally-mandated layer; sharding is the inner, performance layer. They compose cleanly, and the routing layer already built in Part 3 extends with one field.
Cross-region transfers
A Nigerian customer sending to a German one is now a cross-region saga, which is the Part 3 pattern with a stricter rule attached:
- May cross: the minimum payment instruction. Amount, currency, beneficiary identifier, a reference.
- Must not cross: the full customer profile, identity documents, transaction history, behavioural data.
- The mechanism is the same in-transit suspense account per region, so each region's ledger still sums to zero independently.
- Enforcement lives in the transport layer: the cross-region event schema simply has no fields for the data that may not travel. Make the violation unrepresentable rather than forbidden by policy.
Per-jurisdiction compliance as policy, not code
Four jurisdictions, each with its own limits, reporting thresholds, retention periods and screening requirements. The wrong answer is conditionals.
// this is the shape that becomes unmaintainable by the third country,
// and untestable by the fourth.
if (region === 'ng') {
if (amount > 5_000_000_00n) requireReport();
if (tier === 1 && amount > 50_000_00n) reject();
} else if (region === 'eu') {
if (amount > 10_000_00n) requireReport();
...
}Policy as data, evaluated by one engine
CREATE TABLE compliance_policies (
id UUID PRIMARY KEY,
jurisdiction CHAR(2) NOT NULL,
rule_type TEXT NOT NULL, -- limit | report | screen | retain
applies_to JSONB NOT NULL, -- {kind, currency, kyc_tier, corridor}
parameters JSONB NOT NULL, -- {threshold, window, report_to}
-- rules change by regulation, and past decisions must remain
-- explicable under the rule that applied AT THE TIME.
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ,
source_ref TEXT -- the circular or directive it implements
);- A new jurisdiction is configuration, not a release. Compliance staff can prepare rules without engineering capacity.
- Rules are versioned in time, so a 2024 decision can be re-explained under the 2024 rule rather than today's.
- Every decision is auditable to the specific rule and its source document, which is exactly what a regulator asks for.
- The engine is tested once, and each rule is data validated against a schema.
// every compliance decision is RECORDED, with the rule version used.
// "why was this blocked?" must be answerable years later.
interface ComplianceDecision {
journalId: string;
jurisdiction: string;
outcome: 'allow' | 'block' | 'allow_with_report' | 'review';
rulesEvaluated: { policyId: string; version: string; matched: boolean }[];
decidedAt: Date;
// if a human overrode the engine, who, when, and on what authority.
override?: { by: string; reason: string; approvedBy: string };
}Sketch v7: currency-aware, region-aware core
What changed, and the cost accepted
| Change | Driven by | Cost accepted |
|---|---|---|
| Currency on the account, composite FK | Cross-currency sums must be unrepresentable | One wallet per currency per customer, so more accounts |
| Exponent table | JPY has 0 decimals, KWD has 3 | Every format and parse path reads it |
| Invariant per currency | A global sum hides real imbalances | A correction to the round-one check, stated out loud |
| FX position accounts | The bank takes the other side of every conversion | Treasury must monitor and hedge; position limits become alerts |
| Expiring, bound quotes | Market risk on stale rates | Customers can see a quote expire, which the UX must handle |
| Regional deployments | Legal residency, not latency | Full stack per region; cross-region transfers are sagas |
| Compliance policy as data | Four regulators on four timetables | A policy engine to build and a store to govern |
The invariants, restated for v7
- Entries sum to zero per journal, per currency.
- An entry's currency must equal its account's currency, by foreign key.
- Each region's ledger sums to zero independently.
- No amount is formatted or parsed without reading its exponent.
- An expired or reused quote cannot price a conversion.
- Protected personal data has no field in the cross-region schema.