| bank_account:gtb_main | −25000000 | the cash is real and in our account |
| suspense:unattributed_inbound | +25000000 | parked, visible, and owed to someone |
| Σ | 0 | the ledger balances, and the liability is explicit |
Round ten: “prove the money is right”
Nine rounds of machinery, and not one of them can answer the only question a regulator, an auditor or a CFO actually asks: is the money right? This round builds the system that proves it, continuously rather than monthly, and it turns out that every suspense account created in the previous rounds was quietly laying the groundwork.
The pressure: drift is silent
“How do you know your ledger is correct? Not that your code has no bugs. How do you know, today, that the money in your books matches the money that actually exists?”
The honest answer so far is that we do not. The internal invariant proves the ledger is self-consistent, which is a much weaker claim than being correct.
internal consistency (what we have)
Σ entries = 0 per journal per currency
→ proves we did not create money inside our own model
correctness (what is actually required)
our record of external money = their record of it
→ proves the model matches reality
a perfectly balanced ledger can be entirely wrong.
double-entry catches arithmetic errors, never missing or invented facts.
The ways drift appears, all of them quiet
- A payment settled at the provider and our confirmation was never received, so it sits in suspense forever.
- A provider charged a fee we did not book, so their balance is lower than our receivable says.
- A card cleared for an amount different from the authorisation, and the difference was never reconciled.
- A duplicate was posted during an incident and nobody noticed.
- An FX rate was applied on a different day than booked, so the naira value differs.
- A bank reversed a transfer days later and the reversal was not ingested.
None of these break the internal invariant. All of them mean the books are wrong. And each one is individually small, which is exactly why they accumulate unnoticed until an audit or a liquidity crunch surfaces them all at once.
- Prove the trial balance holds, continuously rather than at month end.
- Match every external statement against our ledger daily.
- Classify every mismatch and auto-resolve the known categories.
- Escalate the rest to humans with enough context to decide in minutes.
- Produce an end-of-day close that finance signs off.
- The invariant check runs continuously, detecting a break within 60 seconds.
- External reconciliation completes within 2 hours of statement availability.
- Over 95% of breaks auto-resolve, or the operations team cannot keep up.
- Reconciliation runs against replicas, never adding load to the money path.
The trial balance, continuously
The accounting profession's oldest control, run as a monitoring check rather than a monthly ritual.
-- level 1: every journal balances, per currency. runs every 30 seconds -- over recent entries only, so it is cheap and continuous. SELECT journal_id, currency, SUM(amount) AS drift FROM entries WHERE created_at > now() - interval '5 minutes' GROUP BY journal_id, currency HAVING SUM(amount) <> 0; -- any row is a P1 page. this cannot happen unless something is very wrong. -- level 2: the WHOLE ledger balances, per currency, per region. -- runs hourly against a replica. this is the real trial balance. SELECT currency, SUM(amount) AS total FROM entries GROUP BY currency HAVING SUM(amount) <> 0; -- level 3: assets = liabilities + equity, the accounting identity. SELECT a.gl_class, SUM(e.amount) FROM entries e JOIN accounts a ON a.id = e.account_id WHERE e.currency = $1 GROUP BY a.gl_class; -- assets + liabilities + equity + income + expenses must = 0 -- with our signed convention. finance reads this one directly.
Making level 2 cheap enough to run hourly
Summing 7 billion entries hourly is not viable. The same snapshot technique from Part 3 applies, and for the same reason:
because entries are append-only and ids are monotonic:
total(now) = total(at entry N) + Σ entries where id > N
so keep a running checkpoint per currency per shard,
and each run sums only the entries since the last checkpoint.
cost becomes proportional to new volume, not to history.
the append-only decision from round one pays off for the fourth time.Internal reconciliation: journal vs postings
Before comparing against the outside world, check our own subsystems agree with each other. Part 3 introduced journal-first posting, which created a gap that must be policed.
| Check | Detects | Cadence |
|---|---|---|
Every accepted journal has posted entries | The poster died mid-run, or a shard was unreachable | Every minute |
Every hold is active, or has a resolution | Holds leaked, so available balance is wrong for real customers | Every 5 minutes |
in_transit accounts net to zero per journal | Stuck cross-shard saga from Part 3 | Every minute |
settle_suspense nets to zero per payment | Stuck outbound payment from Part 6 | Every minute |
| Outbox has no rows older than N seconds | The relay is down, so six consumers are blind | Every 30 seconds |
| Snapshot balance equals recomputed balance | Snapshot corruption or a watermark bug | Sampled continuously |
-- the most valuable single query in the system: money that has been
-- taken from a customer and is sitting in limbo.
SELECT a.kind, e.journal_id, e.currency,
SUM(e.amount) AS stuck,
MIN(e.created_at) AS since,
now() - MIN(e.created_at) AS age
FROM entries e
JOIN accounts a ON a.id = e.account_id
WHERE a.kind IN ('in_transit', 'settle_suspense', 'dispute_suspense')
GROUP BY a.kind, e.journal_id, e.currency
HAVING SUM(e.amount) <> 0
AND MIN(e.created_at) < now() - interval '2 minutes'
ORDER BY age DESC;External reconciliation: statements vs ledger
The real thing. Every counterparty holding our money, or holding money for us, sends a statement, and each one must be matched.
| Counterparty | Our account | Their record | Frequency |
|---|---|---|---|
| Settlement bank | bank_account:gtb_main | Bank statement, MT940 or CSV | Daily, sometimes intraday |
| NIBSS | nostro:nibss | Settlement report | Daily |
| Paystack, Flutterwave | psp_receivable:* | Settlement and transaction reports | Daily |
| Card schemes | card_settlement:* | Clearing and settlement files | Daily |
| Correspondent banks | nostro:* | SWIFT MT940 statements | Daily |
| Central bank | cbn_settlement | RTGS account statement | Daily |
for each counterparty, each day:
opening_balance (theirs, agreed yesterday)
+ Σ their credits
− Σ their debits
= closing_balance (theirs)
must equal our account's balance for that counterparty.
any difference is a BREAK, and every break has a cause
that must be found, classified, and either explained or fixed.Three classes of difference, only one of which is a problem
- Timing difference. We booked it today, they book it tomorrow. Legitimate and expected, and it must be tracked to closure rather than dismissed.
- Known fee or adjustment. They deducted a charge we had not booked. Resolved by posting it, and ideally by anticipating it next time.
- Genuine break. A transaction one side has and the other does not, or an amount that differs. This is the one that matters.
Matching: exact, fuzzy, and one-to-many
The engineering core of reconciliation. Matching runs in passes, cheapest and most certain first, with each pass only seeing what earlier passes could not match.
| Pass | Key | Typical yield | Confidence |
|---|---|---|---|
| 1. Exact | Our reference plus amount plus currency | ~92% | Certain |
| 2. Reference only | Our reference, amount differs | ~4% | Certain match, amount break |
| 3. Provider reference | Their reference, which we stored at dispatch | ~2% | Certain |
| 4. Fuzzy | Amount plus date within tolerance plus counterparty | ~1% | Probable. Needs a confidence score |
| 5. Aggregate | One statement line against many of our entries | ~0.5% | Probable |
| Unmatched | Nothing worked | ~0.5% | Becomes a break |
-- pass 1 as a single set-based statement. never loop in the application: -- 400,000 statement lines against 30 million entries is a JOIN, not a for. UPDATE statement_lines s SET matched_entry_id = e.id, match_pass = 1, match_confidence = 1.0 FROM entries e WHERE s.recon_run_id = $run AND s.matched_entry_id IS NULL AND e.external_reference = s.reference AND e.amount = s.amount AND e.currency = s.currency;
The one-to-many problem
their statement: one line, ₦4,820,000 "NIP settlement 14/03"
our ledger: 1,284 individual entries
matching requires finding a subset that sums to the line.
in the general case that is the subset sum problem: NP-complete.
so do not solve the general case. use the structure:
· they settled a known batch → match by batch id
· settlement is daily → match by date plus counterparty
· then verify the sum equals the line
O(n) with the right grouping key, instead of exponential.ourReference was in the Part 6 connector interface.Suspense accounts and the unmatched bucket
When money arrives that we cannot attribute, it has to go somewhere. It cannot be ignored, because it is real, and it cannot be guessed at, because guessing wrong credits the wrong customer.
| suspense:unattributed_inbound | −25000000 | suspense clears |
| wallet B | +25000000 | credited to the right customer at last |
| Σ | 0 | a new journal, linked to the original. nothing was edited |
- Every suspense balance is somebody's money. It is a liability, not a convenience, and it appears on the balance sheet as one.
- Ageing is reported. Anything over 48 hours is escalated; anything over 30 days goes to a formal investigation and possibly to unclaimed-property handling.
- The balance must trend to zero. A suspense account that only grows is an unmanaged liability and a regulatory finding.
- Never net across purposes. Separate accounts for unattributed inbound, failed outbound, disputes and FX residuals, because a single netted balance hides all four problems.
- Nobody may post to suspense to make a reconciliation balance. That converts a detectable break into a permanent lie, and it is the classic path to a serious control failure.
Break classification and auto-resolution
At 10 million transactions a day, even 0.05% unmatched is 5,000 breaks daily. A human queue cannot absorb that, so classification and automated resolution are what make the system operable.
CREATE TABLE recon_breaks ( id UUID PRIMARY KEY, recon_run_id UUID NOT NULL, counterparty TEXT NOT NULL, classification TEXT NOT NULL, amount BIGINT NOT NULL, currency CHAR(3) NOT NULL, statement_line_id UUID, ledger_entry_id BIGINT, -- the resolution, and critically WHO decided and WHY. state TEXT NOT NULL, -- open|investigating|resolved|written_off resolution_journal_id UUID, -- the correcting entries, if any resolved_by TEXT, resolution_note TEXT, -- ageing is the metric that matters operationally first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ ); CREATE INDEX breaks_open ON recon_breaks (first_seen_at) WHERE state <> 'resolved';
Corrections as new entries, never edits
A rule established in Part 2 by revoking UPDATE and
DELETE, and now the thing that makes correction safe and auditable.
| Correction type | Mechanism | What history shows |
|---|---|---|
| Reversal | A new journal with exactly opposite entries | Both the original and the reversal, with a link |
| Adjustment | A new journal for the difference only | The original, plus the delta and its reason |
| Reclassification | Move value between accounts; the customer's total is unchanged | Both accounts' full history |
| Write-off | Move an unrecoverable balance to a loss account | An explicit, approved recognition of loss |
-- the journal carries its own provenance, so a correction is always
-- traceable to what it corrects and why.
ALTER TABLE journal
ADD COLUMN reverses_journal_id UUID REFERENCES journal(id),
ADD COLUMN adjusts_journal_id UUID REFERENCES journal(id),
ADD COLUMN break_id UUID, -- which break prompted this
ADD COLUMN approved_by TEXT; -- four-eyes on corrections
-- and the net effect of an original plus its corrections is a query,
-- not a stored "current value" that could disagree with its history.
WITH RECURSIVE chain AS (
SELECT id, reverses_journal_id, adjusts_journal_id FROM journal WHERE id = $1
UNION ALL
SELECT j.id, j.reverses_journal_id, j.adjusts_journal_id
FROM journal j JOIN chain c
ON j.reverses_journal_id = c.id OR j.adjusts_journal_id = c.id
)
SELECT SUM(e.amount) AS net_effect, e.currency
FROM entries e WHERE e.journal_id IN (SELECT id FROM chain)
GROUP BY e.currency;The nostro position and end-of-day close
Reconciliation feeds a daily ritual that finance owns and engineering enables. It is the moment the day's numbers become official.
- Cut-off. Declare a timestamp. Everything before it is today; everything after is tomorrow. The cut-off must be a recorded fact, not an implicit "when the job ran".
- Ingest all statements for the day from every counterparty.
- Run reconciliation for each, producing matches and classified breaks.
- Auto-resolve what can be resolved, posting correcting entries.
- Produce the trial balance per currency, per region, per GL class.
- Report unresolved breaks by value and age.
- Finance signs off, or the close is held open with named exceptions.
- Freeze the reported figures, so the same date always produces the same report.
Why the frozen report matters
run "yesterday's trial balance" today → one number
run the same report next month → a different number
because corrections have since been posted with earlier
effective dates, which is entirely legitimate.
so: as-reported figures are FROZEN at close, and restatements
are explicit and tracked. "the number changed" must never be
a surprise discovered by an auditor.- Booking date: when we recorded it. Immutable, always "now" at insert.
- Value date: when it takes economic effect. Can be backdated, and drives interest and reporting.
- A correction posted today for a transaction last week has today's booking date and last week's value date, which is exactly why reports must state which date they use.
Sketch v10: reconciliation as a first-class system
What changed, and the cost accepted
| Change | Driven by | Cost accepted |
|---|---|---|
| Continuous invariant checks | Detection latency determines repair cost | Checkpointed sums, and a replica to read from |
| Suspense ageing monitor | Stuck money is invisible otherwise | Alert tuning, since some ageing is legitimate |
| Statement ingestion per counterparty | Six counterparties, six formats | A parser per format, and they change without notice |
| Multi-pass matching engine | 92% exact is not 100% | Fuzzy matching needs confidence scoring and review |
| Break classification with auto-resolution | 5,000 breaks a day cannot be handled manually | Auto-resolution rules are themselves a risk, so they are audited |
| Corrections as linked journals | Auditability, and Part 2's revoked privileges | Net effect is a recursive query rather than a column |
| Frozen close reports | Reported numbers must be reproducible | Restatement becomes an explicit, tracked process |
The question from chapter 112, now answerable
- Every journal balances per currency, checked every 30 seconds.
- The whole ledger balances per currency and region, checked hourly.
- The accounting identity holds across GL classes, checked hourly.
- No suspense balance is ageing beyond its threshold.
- Every counterparty's statement is matched daily, with every difference classified.
- Unresolved breaks are reported by value and age, and the close names them.
- Every correction is a new, linked, approved journal, so history is intact.