| wallet A | −850000 | the actual settled amount |
| card settlement suspense | +850000 | awaiting network settlement |
| Σ | 0 | hold marked captured; the remaining ₦1,500 of headroom returns to available |
Round five: “now add loans, overdraft, liens”
Every product a bank sells is a different story told with the same two verbs, debit
and credit. This round is the payoff for getting the ledger right in round one: we
add overdraft, loans, savings, interest, liens and fee revenue, and the
entries table does not change once. What changes is the layer above
it, and the balance stops being one number.
The pressure: the balance is no longer one number
“Customers can now go overdrawn up to a limit, take loans, hold savings that earn interest, and sometimes a court order freezes part of their money. What happens to your design?”
The instinct is to add tables for each product. The better answer starts by noticing what all four have in common: each one changes what the customer is allowed to spend, without necessarily changing how much money is in the account.
| Product | Effect on money present | Effect on money spendable |
|---|---|---|
| Overdraft | None until used | Increases it, below zero |
| Lien / court freeze | None | Decreases it |
| Card authorisation hold | None yet | Decreases it |
| Loan disbursement | Increases it | Increases it |
| Uncleared inbound cheque | Increases it | Not yet |
Three of these five separate "money present" from "money spendable". So one balance cannot express the state of an account, and that is the structural insight the round turns on.
- An account can be overdrawn to a configured negative limit.
- Funds can be held without being moved, and released or captured later.
- A lien can freeze a specific amount indefinitely, by legal instruction.
- A loan can be disbursed, accrue interest, and be repaid on a schedule.
- Savings accrue interest daily and capitalise monthly.
- Transactions are checked against velocity and cumulative limits.
- The bank can report its own profit from fees and interest.
- Adding a product requires no schema change to
entries. - The zero-sum invariant holds through every product operation.
- The daily accrual batch for 20M accounts completes inside its window.
- Limit checks add under 15 ms to the posting path.
Available vs ledger vs cleared balance
The three-balance model. Getting these names and their relationship right is one of the clearest signals that someone has worked on real banking systems.
| Balance | Definition | Used for |
|---|---|---|
| Ledger balance | SUM(entries.amount). Money actually posted. The accounting truth. | Reconciliation, regulatory reporting, the trial balance. This is the only one that must sum to zero across accounts. |
| Available balance | Ledger, minus active holds, minus liens, plus unused overdraft. | Every authorisation decision. The number the customer sees as spendable. |
| Cleared balance | Ledger, minus funds credited but not yet irrevocably settled. | Risk. Protects against a reversed inbound payment that has already been spent. |
ledger = Σ entries
available = ledger − Σ active_holds − Σ liens + unused_overdraft
cleared = ledger − Σ unsettled_credits
authorisation asks: available ≥ amount
accounting asks: Σ ledger over all accounts = 0
confusing these two is the most expensive mistake in the domainHolds and liens as first-class entries
A hold is a promise that money will probably leave. It is not yet a movement, so
it does not belong in entries. It gets its own table, and the
discipline is that it never touches the ledger until it resolves.
CREATE TABLE holds ( id UUID PRIMARY KEY, account_id UUID NOT NULL, currency CHAR(3) NOT NULL, amount BIGINT NOT NULL CHECK (amount > 0), kind TEXT NOT NULL, -- card_auth | lien | pending_debit state TEXT NOT NULL, -- active | captured | released | expired -- card auths expire by network rule (7 days for most, 30 for hotels). -- a lien has no expiry: it is released only by instruction. expires_at TIMESTAMPTZ, reference TEXT, -- network auth code, or court order number journal_id UUID, -- set when captured: links hold → movement created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- the index that makes the available-balance query cheap CREATE INDEX holds_active ON holds (account_id, currency) WHERE state = 'active';
The three ways a hold ends
| Resolution | Ledger effect | Example |
|---|---|---|
| Captured | Posts entries for the final amount, and marks the hold captured with the journal id | The merchant settles the card transaction |
| Released | None. The hold is simply deactivated and the available balance recovers | The customer cancels; the court lifts the lien |
| Expired | None, by a sweeper job | A card auth the merchant never settled |
The captured amount often differs from the held amount, which is normal and must be modelled rather than treated as an error:
Why a lien is a hold and not a debit
A court freezes ₦500,000. The money is still the customer's and still on the bank's balance sheet, so debiting it would be both legally wrong and an accounting error. What changed is only permission to spend.
-- available balance, in one query. the partial index keeps it fast. SELECT b.ledger - COALESCE(h.held, 0) + COALESCE(o.unused, 0) AS available FROM (SELECT ledger_balance($1, $2) AS ledger) b LEFT JOIN ( SELECT SUM(amount) AS held FROM holds WHERE account_id = $1 AND currency = $2 AND state = 'active' ) h ON TRUE LEFT JOIN ( SELECT limit_amount - used AS unused FROM overdraft_facilities WHERE account_id = $1 AND state = 'active' ) o ON TRUE;
Overdraft as a negative-bound credit line
Round one's balance check was balance >= amount. Overdraft changes
the floor from zero to a negative number, and that is very nearly the whole
feature.
without overdraft: allow if balance − amount ≥ 0
with overdraft: allow if balance − amount ≥ −limit
one constant changes. the ledger does not.CREATE TABLE overdraft_facilities (
account_id UUID PRIMARY KEY,
limit_amount BIGINT NOT NULL CHECK (limit_amount >= 0),
currency CHAR(3) NOT NULL,
state TEXT NOT NULL, -- active | suspended | closed
interest_bps INT NOT NULL, -- basis points per annum on drawn amount
-- which transaction kinds may draw on the facility. a customer may
-- go overdrawn for a POS purchase but not to send money out.
allowed_kinds TEXT[] NOT NULL DEFAULT '{pos,card,direct_debit}',
expires_at TIMESTAMPTZ,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Which transactions may exceed the limit, and who decides
A question that separates a toy design from a real one. Not every transaction type should be allowed to push an account negative, and the policy belongs in data rather than in code branches.
| Transaction kind | May use overdraft? | Reason |
|---|---|---|
| Card / POS purchase | Yes | The classic use. The customer is buying something and the bank earns interest. |
| Direct debit | Yes | Bouncing a utility payment harms the customer more than a small overdraft fee. |
| Loan repayment to us | Yes | Preferring an overdraft to a default is usually right for both sides. |
| Outbound transfer to another bank | No | Irreversible movement of borrowed money off our books. This is the fraud vector. |
| Crypto or gambling merchant | No | Unrecoverable and high risk, typically a regulatory expectation too. |
| Cash withdrawal at ATM | Configurable | Recoverable only from future inflows. Usually a lower sub-limit. |
// the check, with the kind gate. note it stays a single condition
// evaluated inside the posting statement, exactly as in round one.
function floorFor(acct: Account, kind: TxKind, od: Facility | null): bigint {
if (!od || od.state !== 'active') return 0n;
if (od.expiresAt && od.expiresAt < now()) return 0n;
// the gate: this transaction kind is not permitted to draw the facility
if (!od.allowedKinds.includes(kind)) return 0n;
return -od.limitAmount;
}-- and in the posting transaction, the floor is a parameter
INSERT INTO entries (journal_id, account_id, amount, currency)
SELECT $jid, $from, -$amt, $ccy
WHERE (SELECT COALESCE(SUM(amount),0) FROM entries
WHERE account_id = $from AND currency = $ccy)
- $amt >= $floor; -- 0, or −limit for permitted kindsWhere the interest on a drawn overdraft comes from
A negative balance is the bank lending money, so it earns interest. That interest is revenue and must be booked as such:
| wallet A | −3288 | 50000 × 0.24 ÷ 365, in kobo |
| interest income (overdraft) | +3288 | bank revenue account |
| Σ | 0 | the customer goes further negative; the bank recognises income |
Loan disbursement as a ledger movement
A loan feels like a new kind of object. In the ledger it is two accounts and a transfer between them.
- Loan principal receivable (an asset of the bank). Holds what the customer owes. Normally negative from the customer's perspective, positive as a bank asset.
- Interest receivable. Accrued interest earned but not yet paid.
- Optionally a fee receivable for origination fees not deducted up front.
| loan_receivable:L-991 | −50000000 | the bank's asset: customer owes this |
| wallet A | +49500000 | net cash the customer receives |
| fee income (origination) | +500000 | recognised immediately |
| Σ | 0 | one journal, three entries, invariant intact |
Nothing new was needed. The entries table did not change, the
posting code did not change, and the invariant check did not change. That is the
dividend from round one, and it is worth saying explicitly in the room.
How the loans service calls the ledger
This is the integration question, and the answer follows the rule set in round one: the loans service does not write to the ledger database. It calls the ledger service, which is the sole writer.
// loans service → ledger service. one call, fully specified, idempotent.
await ledger.post({
idempotencyKey: `loan-disb-${loanId}`, // natural key: one per loan
kind: 'loan.disbursement',
entries: [
{ account: `loan_receivable:${loanId}`, amount: '-50000000' },
{ account: `wallet:${customerWallet}`, amount: '49500000' },
{ account: 'fee_income:origination', amount: '500000' }
],
currency: 'NGN',
metadata: { loan_id: loanId, product: 'salary_advance' }
});
// the ledger validates that entries sum to zero and REFUSES if they do not.
// a caller cannot break the invariant even with a bug, which is why the
// "one writer" rule from round one matters more with every new product.disburseLoan() method, it would need to know what
a loan is, and then a savings product, and then a card, until the ledger contains
every product rule in the bank. Instead it accepts balanced entries and
validates the invariant. Product knowledge lives in product services; the
ledger knows only debits, credits and zero. Part 14 designs this API properly.Amortisation, accrual, and the daily batch
Interest is earned continuously and paid periodically, and the gap between those two facts is where accrual accounting lives.
daily accrual on a loan:
interest = outstanding_principal × annual_rate ÷ day_count
day_count conventions (they differ, and the difference is real money):
Actual/365 most retail lending
Actual/360 money markets, US commercial
30/360 bonds
the convention must be stored on the product, never assumed| interest_receivable:L-991 | −246575 | the bank's asset grows |
| interest_income | +246575 | revenue recognised today |
| Σ | 0 | the customer's wallet is untouched; nothing is due yet |
And when the customer repays, the payment is allocated in a defined order, which matters both commercially and legally:
| wallet A | −6000000 | customer pays |
| fee_receivable:L-991 | +200000 | 1. late fee cleared first |
| interest_receivable:L-991 | +1400000 | 2. accrued interest next |
| loan_receivable:L-991 | +4400000 | 3. remainder reduces principal |
| Σ | 0 | four entries, one journal, allocation fully auditable |
Running the batch for 20 million accounts
The naive version is a loop that takes eleven hours. The design has to be deliberate:
- Partition by shard. 4,096 logical shards means 4,096 independent units of work that parallelise perfectly.
- Journal-first. Accrual is an asynchronous flow, so it appends to the journal and posts behind it. Part 3's split applies exactly here.
- Idempotent per day. The idempotency key is
accrual-{loanId}-{date}, so a re-run of a failed batch cannot double-charge anyone. This is the single most important property of the batch. - Checkpointed. Progress is recorded per shard, so a crash resumes rather than restarts.
- Rate limited. The batch must not starve interactive traffic. It runs at a capped rate and backs off when posting latency rises.
2M active loans ÷ 4,096 shards ≈ 490 loans per shard
at 200 postings/s per shard worker → 2.5 s per shard
64 workers in parallel → ~2.6 minutes for the whole bank
versus one sequential loop at 50/s → 11 hoursSavings, interest accrual, and rounding rules
Savings is the mirror image of a loan: the bank owes the customer, and interest flows the other way. The structure is identical, and the interesting problem is rounding.
| interest_expense | −21917 | a cost to the bank |
| interest_payable:A-771 | +21917 | the bank's liability to the customer |
| Σ | 0 | capitalised into the wallet monthly, as a separate journal |
The rounding problem, and why it must be explicit
200,000.00 × 0.04 ÷ 365 = 21.9178082... kobo... which is not an integer
round half up → 21918 bank pays 0.0000082 more
round half down → 21917 bank pays less
truncate → 21917 always in the bank's favour
banker's rounding → 21918 unbiased over many operations
× 20M accounts × 365 days = the choice is worth millions- The rule is stored on the product, versioned, and never inferred from code.
- Truncation in the bank's favour is a regulatory risk in many jurisdictions. Do not choose it by accident.
- Rounding happens once, at the point of posting. Never round an intermediate value.
- The residual must be booked. When an amount is split three ways and does not divide evenly, the remainder goes to a defined account rather than vanishing.
// splitting 100 kobo three ways: 33, 33, 33 loses 1 kobo.
// the largest-remainder method assigns it deterministically, and the
// total is guaranteed to match, which keeps the invariant intact.
function splitExact(total: bigint, weights: bigint[]): bigint[] {
const sum = weights.reduce((a, b) => a + b, 0n);
const base = weights.map(w => (total * w) / sum); // integer division
let remainder = total - base.reduce((a, b) => a + b, 0n);
// distribute the remainder to the largest fractional parts first
const order = weights
.map((w, i) => ({ i, frac: (total * w) % sum }))
.sort((a, b) => (b.frac > a.frac ? 1 : -1));
for (const { i } of order) {
if (remainder === 0n) break;
base[i] += 1n; remainder -= 1n;
}
return base; // sums to total, exactly, always
}Limits: velocity, per-transaction, cumulative
Limits exist for regulation, for fraud control, and for the customer's own protection. They are all the same mechanism with different windows.
| Limit type | Window | Driven by | Storage |
|---|---|---|---|
| Per transaction | Instant | Regulation, KYC tier | A column. Stateless check. |
| Velocity | Rolling window: 5 per hour | Fraud | Counter with expiry. Redis. |
| Daily cumulative | Calendar day | Regulation, KYC tier | Counter, reset at local midnight. |
| Monthly cumulative | Calendar month | Regulation, tax reporting | Counter, or aggregate from the ledger. |
| Per counterparty | Rolling | Fraud, sanctions | Counter keyed by the pair. |
The consistency problem with counters
A limit counter in Redis is fast and can be lost. A counter in Postgres is durable and is a hot row. The resolution is to be explicit about which failure each limit can tolerate.
- Hard regulatory limits are computed from the ledger itself, which is the only authoritative source, using the daily aggregate. Slower, and unarguable in an audit.
- Velocity and fraud limits use Redis counters. If Redis loses a counter, a customer briefly gets a slightly higher allowance, which is an acceptable risk for a fraud heuristic.
- The decision is logged with the counter values used, so a dispute can be reconstructed even though the counter has since expired.
// atomic check-and-increment in one Redis round trip. doing this as
// GET then INCR would be exactly the lost-update bug from Part 2.
const VELOCITY = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
local cap = tonumber(ARGV[2])
if current + amount > cap then
return {0, current} -- denied, report the current value
end
redis.call('INCRBY', KEYS[1], amount)
redis.call('EXPIRE', KEYS[1], ARGV[3], 'NX') -- set TTL only on creation
return {1, current + amount} -- allowed
`;Company accounts, GL mapping, profit tracking
The bank is a participant in its own ledger. Its accounts are where profit becomes visible, and they are what the finance team actually looks at.
| GL class | Example accounts | Sign convention |
|---|---|---|
| Assets | Cash at central bank, loans receivable, interest receivable, nostro accounts | Debit increases |
| Liabilities | Customer wallets, savings, interest payable | Credit increases |
| Equity | Share capital, retained earnings | Credit increases |
| Income | Fee income, interest income, FX spread income | Credit increases |
| Expenses | Interest expense, provider costs, loan loss provisions | Debit increases |
Mapping ledger accounts to the GL
ALTER TABLE accounts ADD COLUMN gl_code TEXT, -- '2001' customer deposits ADD COLUMN gl_class TEXT; -- asset|liability|equity|income|expense -- the trial balance: the report finance asks for, straight from entries. SELECT a.gl_class, a.gl_code, SUM(e.amount) AS balance FROM entries e JOIN accounts a ON a.id = e.account_id WHERE e.created_at < $as_of GROUP BY a.gl_class, a.gl_code ORDER BY a.gl_class, a.gl_code; -- and the sum of ALL of it must still be zero. that is the trial balance -- proving out, and it is the same invariant from chapter 16.
Profit, derived rather than tracked
profit = Σ income − Σ expenses over a period
income: transfer fees, card interchange, FX spread,
loan interest, overdraft interest, late fees
expenses: interest paid on savings, NIBSS and provider fees,
card scheme fees, loan loss provisions
no profit table. it is a query over the same entries.The same structure answers finer questions without new storage: profit per product by filtering on journal kind, per customer segment by joining account metadata, per corridor by filtering FX journals. This is the moment the double-entry decision from round one pays its largest dividend.
Fees, and where fee revenue is booked
Fees look trivial and contain three real decisions.
- Gross: the recipient receives the full amount and the sender pays the fee separately. Four entries.
- Net: the fee is deducted from the transferred amount and the recipient receives less. Three entries.
- The choice is a product decision with tax and disclosure consequences, so it must be explicit on the product rather than implicit in code.
| wallet A | −50000 | the transfer |
| wallet B | +50000 | full amount received |
| wallet A | −1000 | the fee |
| fee_income:transfer:41 | +1000 | sharded sub-account, per Part 3 |
| Σ | 0 | one journal, four entries |
- A transfer fee is earned immediately, because the service is complete.
- A card annual fee is earned over the year, so it is booked to deferred income and released monthly.
- A loan origination fee may be amortised over the loan term under the applicable accounting standard.
- Getting this wrong misstates revenue, which is an audit finding rather than a bug report.
- When a transfer is reversed, is the fee refunded? A policy question that must be encoded.
- A refund is a new journal with opposite entries, never an update or delete of the original.
- The reversal references the original journal id, so the pair is traceable and the net effect is visible in reporting.
-- reversal: never UPDATE, never DELETE. always a new, linked journal. INSERT INTO journal (id, kind, idempotency_key, reverses_journal_id) VALUES ($newJid, 'transfer.reversal', 'rev-' || $origJid, $origJid); -- then the opposite entries. history is append-only: both the original -- and the correction are permanently visible, which is what an auditor -- requires and what "REVOKE UPDATE, DELETE" from Part 2 enforces.
Sketch v5: product engine over one ledger
What changed, and what pointedly did not
| Component | Change |
|---|---|
entries | Unchanged. Every product in this round is expressed in the round-one schema. |
journal | Two nullable columns: reverses_journal_id and a metadata field. |
accounts | Two columns for GL mapping. New kinds of account, which is data rather than schema. |
New: holds | Permission state, deliberately not in the ledger. |
New: overdraft_facilities | The floor for the balance check, plus which kinds may draw it. |
| New: limit counters | Redis for velocity, ledger aggregates for regulatory limits. |
| New: product services | Loans, savings, cards and fees. Each owns its rules and calls the posting API. |
| New: accrual batch | Sharded, checkpointed, idempotent per day. |
The invariants after five rounds
- Entries per journal sum to zero, for every product, validated at the posting API before the write.
- Authorisation uses available balance; reconciliation uses ledger balance. Never interchanged.
- A hold never touches the ledger until it is captured.
- Corrections are new opposite entries, linked to the original.
- The accrual batch is idempotent per day, so a re-run is a no-op.
- No product service writes to the ledger database directly.
entries table has not changed since round one.
That is the return on choosing double-entry: a product is a recipe for balanced
entries, so adding one is a new service rather than a migration. The two things
I would highlight are the three-balance model, because authorising against
the wrong balance is how banks accidentally lend money, and the idempotency of
the accrual batch, because a re-run must be a no-op. What I have not handled is
that all of this money is still trapped inside our system."Which is the cue for round six, where money starts crossing the boundary into systems nobody here controls.