Part 10 · 10 chapters · ~20 min

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.

112

The pressure: drift is silent

interviewer

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

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

sources of drift
  1. A payment settled at the provider and our confirmation was never received, so it sits in suspense forever.
  2. A provider charged a fee we did not book, so their balance is lower than our receivable says.
  3. A card cleared for an amount different from the authorisation, and the difference was never reconciled.
  4. A duplicate was posted during an incident and nobody noticed.
  5. An FX rate was applied on a different day than booked, so the naira value differs.
  6. 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.

functional, new
  1. Prove the trial balance holds, continuously rather than at month end.
  2. Match every external statement against our ledger daily.
  3. Classify every mismatch and auto-resolve the known categories.
  4. Escalate the rest to humans with enough context to decide in minutes.
  5. Produce an end-of-day close that finance signs off.
non-functional, new
  1. The invariant check runs continuously, detecting a break within 60 seconds.
  2. External reconciliation completes within 2 hours of statement availability.
  3. Over 95% of breaks auto-resolve, or the operations team cannot keep up.
  4. Reconciliation runs against replicas, never adding load to the money path.
113

The trial balance, continuously

The accounting profession's oldest control, run as a monitoring check rather than a monthly ritual.

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

worked numbers
          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.
why continuous beats monthly, concretely
A break found 60 seconds after it happens has one candidate cause: whatever was deployed or processed in the last minute. The same break found at month end has thirty days of changes to search through, and the affected customers have already spent, disputed and complained. Detection latency is the single biggest determinant of how expensive a break is to fix, and that argument is what justifies the infrastructure.
114

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.

CheckDetectsCadence
Every accepted journal has posted entriesThe poster died mid-run, or a shard was unreachableEvery minute
Every hold is active, or has a resolutionHolds leaked, so available balance is wrong for real customersEvery 5 minutes
in_transit accounts net to zero per journalStuck cross-shard saga from Part 3Every minute
settle_suspense nets to zero per paymentStuck outbound payment from Part 6Every minute
Outbox has no rows older than N secondsThe relay is down, so six consumers are blindEvery 30 seconds
Snapshot balance equals recomputed balanceSnapshot corruption or a watermark bugSampled continuously
code
-- 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;
the payoff for every suspense account we created
Rounds three, six and eight each parked money in a named suspense account while an outcome was uncertain. That decision was justified then on correctness grounds, and here it pays a second dividend: a suspense account with a non-zero net balance for a journal is, by definition, a stuck operation. One query finds every in-flight failure in the entire bank, regardless of which subsystem produced it. The observability was a free consequence of modelling uncertainty honestly.
115

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.

CounterpartyOur accountTheir recordFrequency
Settlement bankbank_account:gtb_mainBank statement, MT940 or CSVDaily, sometimes intraday
NIBSSnostro:nibssSettlement reportDaily
Paystack, Flutterwavepsp_receivable:*Settlement and transaction reportsDaily
Card schemescard_settlement:*Clearing and settlement filesDaily
Correspondent banksnostro:*SWIFT MT940 statementsDaily
Central bankcbn_settlementRTGS account statementDaily
worked numbers
          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

classification before investigation
  1. Timing difference. We booked it today, they book it tomorrow. Legitimate and expected, and it must be tracked to closure rather than dismissed.
  2. Known fee or adjustment. They deducted a charge we had not booked. Resolved by posting it, and ideally by anticipating it next time.
  3. Genuine break. A transaction one side has and the other does not, or an amount that differs. This is the one that matters.
the discipline that keeps this honest
Never close a reconciliation with an unexplained difference, however small. "It's only ₦340" is how a systemic bug hides: the same ₦340 appears daily, for months, and is eventually found to be a rounding error on 40,000 transactions. A small break is a large break that has not happened yet, and treating the threshold as a triage priority rather than a tolerance is the distinction that matters.
116

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.

PassKeyTypical yieldConfidence
1. ExactOur reference plus amount plus currency~92%Certain
2. Reference onlyOur reference, amount differs~4%Certain match, amount break
3. Provider referenceTheir reference, which we stored at dispatch~2%Certain
4. FuzzyAmount plus date within tolerance plus counterparty~1%Probable. Needs a confidence score
5. AggregateOne statement line against many of our entries~0.5%Probable
UnmatchedNothing worked~0.5%Becomes a break
code
-- 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

worked numbers
          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.
the design lesson worth stating
Reconciliation is only hard when you have thrown away the information that makes it easy. If every outbound payment carries our reference, and every settlement carries a batch id we generated, matching is a join. Teams that find reconciliation intractable usually have a data capture problem upstream rather than a matching problem. Design for reconciliation at dispatch time, which is why ourReference was in the Part 6 connector interface.
matching passes
exact, then reference, then fuzzy, then aggregate
swipe the figure sideways, or tap expand for full screen
1/8
unmatched
Twenty statement lines from a counterparty on the left, our ledger entries on the right. Nothing is matched yet, and doing this with a loop over 400,000 lines against 30 million entries would never finish.
117

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.

₦250,000 arrives with an unreadable narration · journal kind: inbound.unattributed
bank_account:gtb_main−25000000the cash is real and in our account
suspense:unattributed_inbound+25000000parked, visible, and owed to someone
Σ0the ledger balances, and the liability is explicit
two days later, the sender is identified · journal kind: inbound.attributed
suspense:unattributed_inbound−25000000suspense clears
wallet B+25000000credited to the right customer at last
Σ0a new journal, linked to the original. nothing was edited
rules for suspense accounts
  1. Every suspense balance is somebody's money. It is a liability, not a convenience, and it appears on the balance sheet as one.
  2. Ageing is reported. Anything over 48 hours is escalated; anything over 30 days goes to a formal investigation and possibly to unclaimed-property handling.
  3. The balance must trend to zero. A suspense account that only grows is an unmanaged liability and a regulatory finding.
  4. Never net across purposes. Separate accounts for unattributed inbound, failed outbound, disputes and FX residuals, because a single netted balance hides all four problems.
  5. 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.
the control point interviewers probe
Rule five is the one that matters. Suspense accounts exist to hold genuine uncertainty, and they are the obvious place to hide a difference you cannot explain. So posting to a suspense account requires a reason code, an approver, and an ageing report that makes the balance impossible to ignore. The mechanism that makes drift visible is also the mechanism that could conceal it, and knowing that is what separates someone who has worked in finance engineering from someone who has read about it.
118

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.

timing_difference We booked today, they book tomorrow. Confirmed by matching against the next day's statement. auto-resolve
known_fee A provider charge matching a configured fee schedule. Posted automatically to the expense account. auto-post
fx_revaluation Same foreign amount, different local value because of the rate date. Posted to an FX revaluation account. auto-post
rounding Difference under the minor-unit tolerance for the currency. Posted to a rounding difference account. auto-post
amount_mismatch Matched on reference, amount differs beyond tolerance. Could be a partial settlement, a deduction, or a real error. analyst
missing_in_ledger They have a transaction we do not. Money moved without our knowledge, or a confirmation was lost. analyst
missing_at_counterparty We have a transaction they do not. Either it never reached them, or their file is incomplete. analyst
duplicate_detected The same transaction appears twice on one side. A real posting error, and the reason idempotency exists. page
unexplained Classification failed entirely. Either a new failure mode or a bug in reconciliation itself. page
code
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';
the metric to put on the dashboard
Not break count. Total unresolved break value, and the age of the oldest break. A thousand ₦10 timing differences are noise; one unexplained ₦4,000,000 break that is nine days old is an incident. Value and age, not count, and proposing that distinction unprompted shows you have run an operations queue rather than only designed one.
119

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 typeMechanismWhat history shows
ReversalA new journal with exactly opposite entriesBoth the original and the reversal, with a link
AdjustmentA new journal for the difference onlyThe original, plus the delta and its reason
ReclassificationMove value between accounts; the customer's total is unchangedBoth accounts' full history
Write-offMove an unrecoverable balance to a loss accountAn explicit, approved recognition of loss
code
-- 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;
why an auditor cares so much about this
If a ledger can be edited, then every number in it is only as trustworthy as the access controls around it, and proving what a balance was last Tuesday becomes impossible. Append-only with linked corrections means the ledger is evidence rather than merely a record: the mistake, the correction, the approver and the reason are all permanently visible. The audit property and the engineering property are the same property, which is a satisfying thing to be able to say.
120

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.

the close sequence
  1. 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".
  2. Ingest all statements for the day from every counterparty.
  3. Run reconciliation for each, producing matches and classified breaks.
  4. Auto-resolve what can be resolved, posting correcting entries.
  5. Produce the trial balance per currency, per region, per GL class.
  6. Report unresolved breaks by value and age.
  7. Finance signs off, or the close is held open with named exceptions.
  8. Freeze the reported figures, so the same date always produces the same report.

Why the frozen report matters

worked numbers
          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.
two dates on every entry, and the difference matters
  1. Booking date: when we recorded it. Immutable, always "now" at insert.
  2. Value date: when it takes economic effect. Can be backdated, and drives interest and reporting.
  3. 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.
the sentence that signals real experience
"Every entry carries both a booking date and a value date, and every report states which one it is using. Without that distinction, a backdated correction silently changes a number that has already been reported to a regulator, and the first you hear of it is a question you cannot answer."
121

Sketch v10: reconciliation as a first-class system

What changed, and the cost accepted

ChangeDriven byCost accepted
Continuous invariant checksDetection latency determines repair costCheckpointed sums, and a replica to read from
Suspense ageing monitorStuck money is invisible otherwiseAlert tuning, since some ageing is legitimate
Statement ingestion per counterpartySix counterparties, six formatsA parser per format, and they change without notice
Multi-pass matching engine92% exact is not 100%Fuzzy matching needs confidence scoring and review
Break classification with auto-resolution5,000 breaks a day cannot be handled manuallyAuto-resolution rules are themselves a risk, so they are audited
Corrections as linked journalsAuditability, and Part 2's revoked privilegesNet effect is a recursive query rather than a column
Frozen close reportsReported numbers must be reproducibleRestatement becomes an explicit, tracked process

The question from chapter 112, now answerable

how we know the money is right
  1. Every journal balances per currency, checked every 30 seconds.
  2. The whole ledger balances per currency and region, checked hourly.
  3. The accounting identity holds across GL classes, checked hourly.
  4. No suspense balance is ageing beyond its threshold.
  5. Every counterparty's statement is matched daily, with every difference classified.
  6. Unresolved breaks are reported by value and age, and the close names them.
  7. Every correction is a new, linked, approved journal, so history is intact.
how to close round ten
"The answer to 'how do you know' is seven checks at four cadences, and the key distinction is between internal consistency, which double-entry gives me for free, and correctness against the outside world, which only reconciliation can establish. A balanced ledger can be completely wrong. What I would highlight is that every suspense account from rounds three, six and eight became a monitoring surface here: one query over them finds every stuck operation in the bank. What is still missing is that nobody can ask the business questions, because the only place the data lives is the money path."
architecture v10
continuous internal checks, daily external matching
swipe the figure sideways, or tap expand for full screen
1/8
where to read
Reconciliation touches the ledger constantly, so the first design decision is where it reads from.