Part 5 · 11 chapters · ~20 min

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.

55

The pressure: the balance is no longer one number

interviewer

“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.

ProductEffect on money presentEffect on money spendable
OverdraftNone until usedIncreases it, below zero
Lien / court freezeNoneDecreases it
Card authorisation holdNone yetDecreases it
Loan disbursementIncreases itIncreases it
Uncleared inbound chequeIncreases itNot 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.

functional, new
  1. An account can be overdrawn to a configured negative limit.
  2. Funds can be held without being moved, and released or captured later.
  3. A lien can freeze a specific amount indefinitely, by legal instruction.
  4. A loan can be disbursed, accrue interest, and be repaid on a schedule.
  5. Savings accrue interest daily and capitalise monthly.
  6. Transactions are checked against velocity and cumulative limits.
  7. The bank can report its own profit from fees and interest.
non-functional, new
  1. Adding a product requires no schema change to entries.
  2. The zero-sum invariant holds through every product operation.
  3. The daily accrual batch for 20M accounts completes inside its window.
  4. Limit checks add under 15 ms to the posting path.
56

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.

BalanceDefinitionUsed for
Ledger balanceSUM(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 balanceLedger, minus active holds, minus liens, plus unused overdraft.Every authorisation decision. The number the customer sees as spendable.
Cleared balanceLedger, minus funds credited but not yet irrevocably settled.Risk. Protects against a reversed inbound payment that has already been spent.
worked numbers
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 domain
why the distinction is not pedantry
If you authorise against the ledger balance, a customer with a ₦10,000 balance and a ₦10,000 card hold can spend ₦10,000 again, and you have lent money you never agreed to lend. If you reconcile against the available balance, your books will never balance and you will chase a phantom discrepancy for weeks. Authorise on available. Reconcile on ledger. Never mix them.
three balances
how holds, liens and overdraft move each one
swipe the figure sideways, or tap expand for full screen
1/8
10,000
An account holds 10,000 kobo. With no holds, liens or overdraft, all three balances are the same number, which is why one balance seems sufficient at first.
57

Holds 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.

code
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

ResolutionLedger effectExample
CapturedPosts entries for the final amount, and marks the hold captured with the journal idThe merchant settles the card transaction
ReleasedNone. The hold is simply deactivated and the available balance recoversThe customer cancels; the court lifts the lien
ExpiredNone, by a sweeper jobA 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:

capture: held ₦10,000, settled ₦8,500 · fuel station, tip, or partial shipment
wallet A−850000the actual settled amount
card settlement suspense+850000awaiting network settlement
Σ0hold marked captured; the remaining ₦1,500 of headroom returns to available

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.

the test to apply to any new product
Ask: has money moved, or has permission changed? If money moved, it is entries. If permission changed, it is a hold, a limit, or a flag. Getting this wrong in either direction produces a ledger that cannot be reconciled, and this one question resolves nearly every modelling decision in this part.
code
-- 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;
58

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.

worked numbers
          without overdraft:  allow if  balance − amount  ≥ 0

          with overdraft:     allow if  balance − amount  ≥ −limit


one constant changes. the ledger does not.
code
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 kindMay use overdraft?Reason
Card / POS purchaseYesThe classic use. The customer is buying something and the bank earns interest.
Direct debitYesBouncing a utility payment harms the customer more than a small overdraft fee.
Loan repayment to usYesPreferring an overdraft to a default is usually right for both sides.
Outbound transfer to another bankNoIrreversible movement of borrowed money off our books. This is the fraud vector.
Crypto or gambling merchantNoUnrecoverable and high risk, typically a regulatory expectation too.
Cash withdrawal at ATMConfigurableRecoverable only from future inflows. Usually a lower sub-limit.
code
// 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;
}
code
-- 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 kinds

Where 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:

daily overdraft interest on a ₦50,000 drawn balance at 2,400 bps · 24% per annum
wallet A−328850000 × 0.24 ÷ 365, in kobo
interest income (overdraft)+3288bank revenue account
Σ0the customer goes further negative; the bank recognises income
59

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.

the accounts a loan creates
  1. 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.
  2. Interest receivable. Accrued interest earned but not yet paid.
  3. Optionally a fee receivable for origination fees not deducted up front.
disburse a ₦500,000 loan, with a ₦5,000 origination fee deducted · journal kind: loan.disbursement
loan_receivable:L-991−50000000the bank's asset: customer owes this
wallet A+49500000net cash the customer receives
fee income (origination)+500000recognised immediately
Σ0one 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.

code
// 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.
why the ledger takes entries rather than a loan object
If the ledger had a 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.
60

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.

worked numbers
          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
daily interest accrual, ₦500,000 principal at 1,800 bps · not yet due, not yet paid
interest_receivable:L-991−246575the bank's asset grows
interest_income+246575revenue recognised today
Σ0the 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:

repayment of ₦60,000 · allocation order: fees, then interest, then principal
wallet A−6000000customer pays
fee_receivable:L-991+2000001. late fee cleared first
interest_receivable:L-991+14000002. accrued interest next
loan_receivable:L-991+44000003. remainder reduces principal
Σ0four 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:

batch design
  1. Partition by shard. 4,096 logical shards means 4,096 independent units of work that parallelise perfectly.
  2. Journal-first. Accrual is an asynchronous flow, so it appends to the journal and posts behind it. Part 3's split applies exactly here.
  3. 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.
  4. Checkpointed. Progress is recorded per shard, so a crash resumes rather than restarts.
  5. Rate limited. The batch must not starve interactive traffic. It runs at a capped rate and backs off when posting latency rises.
worked numbers
          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 hours
the interview question hiding here
"What if the batch runs twice?" The answer must be nothing happens, because of the date-scoped idempotency key. If your answer involves a flag column you update after processing, you have a dual write inside your batch and a partial re-run will double-post. Make the key carry the date.
61

Savings, 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.

daily savings interest, ₦200,000 balance at 400 bps · accrued, not yet capitalised
interest_expense−21917a cost to the bank
interest_payable:A-771+21917the bank's liability to the customer
Σ0capitalised into the wallet monthly, as a separate journal

The rounding problem, and why it must be explicit

worked numbers
          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
rounding rules for a ledger
  1. The rule is stored on the product, versioned, and never inferred from code.
  2. Truncation in the bank's favour is a regulatory risk in many jurisdictions. Do not choose it by accident.
  3. Rounding happens once, at the point of posting. Never round an intermediate value.
  4. 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.
code
// 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
}
62

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 typeWindowDriven byStorage
Per transactionInstantRegulation, KYC tierA column. Stateless check.
VelocityRolling window: 5 per hourFraudCounter with expiry. Redis.
Daily cumulativeCalendar dayRegulation, KYC tierCounter, reset at local midnight.
Monthly cumulativeCalendar monthRegulation, tax reportingCounter, or aggregate from the ledger.
Per counterpartyRollingFraud, sanctionsCounter 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.

a tiered approach
  1. 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.
  2. 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.
  3. The decision is logged with the counter values used, so a dispute can be reconstructed even though the counter has since expired.
code
// 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
`;
the ordering that saves you money
Check limits before taking any database lock and before calling any external provider. A denied transaction should cost a Redis round trip, not a held row lock and a provider call. Cheapest and most likely-to-fail checks first is a principle that applies well beyond limits.
63

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 classExample accountsSign convention
AssetsCash at central bank, loans receivable, interest receivable, nostro accountsDebit increases
LiabilitiesCustomer wallets, savings, interest payableCredit increases
EquityShare capital, retained earningsCredit increases
IncomeFee income, interest income, FX spread incomeCredit increases
ExpensesInterest expense, provider costs, loan loss provisionsDebit increases
the fact that surprises engineers
A customer's wallet is a liability of the bank, not an asset. Their money is money the bank owes them. This is why a customer's credit balance appears as a credit in the bank's books, and it is the single most common source of sign confusion when engineers first meet a general ledger. Once you internalise it, the rest of the chart of accounts follows.

Mapping ledger accounts to the GL

code
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

worked numbers
          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.

64

Fees, and where fee revenue is booked

Fees look trivial and contain three real decisions.

decision one: gross or net
  1. Gross: the recipient receives the full amount and the sender pays the fee separately. Four entries.
  2. Net: the fee is deducted from the transferred amount and the recipient receives less. Three entries.
  3. The choice is a product decision with tax and disclosure consequences, so it must be explicit on the product rather than implicit in code.
gross: send ₦500, ₦10 fee paid by sender · recipient gets the full ₦500
wallet A−50000the transfer
wallet B+50000full amount received
wallet A−1000the fee
fee_income:transfer:41+1000sharded sub-account, per Part 3
Σ0one journal, four entries
decision two: when revenue is recognised
  1. A transfer fee is earned immediately, because the service is complete.
  2. A card annual fee is earned over the year, so it is booked to deferred income and released monthly.
  3. A loan origination fee may be amortised over the loan term under the applicable accounting standard.
  4. Getting this wrong misstates revenue, which is an audit finding rather than a bug report.
decision three: reversal
  1. When a transfer is reversed, is the fee refunded? A policy question that must be encoded.
  2. A refund is a new journal with opposite entries, never an update or delete of the original.
  3. The reversal references the original journal id, so the pair is traceable and the net effect is visible in reporting.
code
-- 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.
65

Sketch v5: product engine over one ledger

What changed, and what pointedly did not

ComponentChange
entriesUnchanged. Every product in this round is expressed in the round-one schema.
journalTwo nullable columns: reverses_journal_id and a metadata field.
accountsTwo columns for GL mapping. New kinds of account, which is data rather than schema.
New: holdsPermission state, deliberately not in the ledger.
New: overdraft_facilitiesThe floor for the balance check, plus which kinds may draw it.
New: limit countersRedis for velocity, ledger aggregates for regulatory limits.
New: product servicesLoans, savings, cards and fees. Each owns its rules and calls the posting API.
New: accrual batchSharded, checkpointed, idempotent per day.

The invariants after five rounds

enforced, not assumed
  1. Entries per journal sum to zero, for every product, validated at the posting API before the write.
  2. Authorisation uses available balance; reconciliation uses ledger balance. Never interchanged.
  3. A hold never touches the ledger until it is captured.
  4. Corrections are new opposite entries, linked to the original.
  5. The accrual batch is idempotent per day, so a re-run is a no-op.
  6. No product service writes to the ledger database directly.
how to close round five
"Five products, and the 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.

architecture v5
product services, one posting interface
swipe the figure sideways, or tap expand for full screen
1/8
five products
Five product services arrive: loans, savings, overdraft, cards and fees. Each owns its own rules, schedules and pricing.