Reconciliation Engineering

Prerequisite: The Ledger

Your ledger says you sent $42,000 to a customer. Stripe says the payout was $41,850. Your bank statement shows a debit of $41,850.15. Three systems, three numbers, zero agreement. The $150 gap is a processing fee Stripe deducted before settlement. The 15 cents is a wire fee your bank applied. Both are correct — but until your reconciliation system can explain the gaps, you have a break, and a break is an open question about where money went.

Reconciliation is not “do the books balance?” It’s “can we prove, for every entry in every system, that every other system agrees — and when they don’t, can we explain why within a bounded time?”

Three-Way Matching

A single ledger in isolation is trivially consistent — it agrees with itself. The moment money crosses a system boundary, you need proof that both sides recorded the same thing. In production, you have at least three systems that must agree.

System What it records What it knows
Your ledger Intent and execution — every entry you posted Everything, by construction
Payment processor (Stripe, Adyen, etc.) Transaction events — charges, refunds, payouts, fees Its view of the transaction lifecycle
Bank statement (via BAI2, MT940, ISO camt.053) Settlement — what actually moved in/out of your account Cash position only, no transaction context

Three-way reconciliation matches each internal ledger entry to a processor event and a bank statement line. A clean match across all three is a confirmed transaction. A match across two but not the third is a timing difference or a data gap. A match against none is a potential problem.

Ledger Entry ←→ Processor Event ←→ Bank Statement Line
    ↕                  ↕                     ↕
 reference_id    transaction_id         description + amount

The join keys between these systems are never perfectly aligned. Your ledger has an internal entry_id and a processor reference_id. The processor has a transaction_id and a payout_id. The bank statement has a free-text description and an amount. Your reconciliation engine must bridge these identifiers across all three systems.

Bank statements often arrive as flat files (BAI2 in the US, MT940 internationally) with limited structure. A single bank statement line may aggregate multiple transactions into one lump settlement. Your matching engine must handle one-to-many and many-to-one relationships, not just one-to-one.

Deterministic Matching

Start with exact matches — they’re fast, unambiguous, and should cover 85–95% of your transaction volume.

A deterministic match requires agreement on a set of fields with zero tolerance. The strongest match key is a shared reference ID: your system and the counterparty both recorded the same identifier at transaction time.

SELECT l.entry_id, p.transaction_id
FROM ledger_entries l
JOIN processor_events p
  ON l.processor_reference = p.transaction_id
  AND l.amount = p.amount
  AND l.currency = p.currency
WHERE l.reconciliation_status = 'unmatched'
  AND p.reconciliation_status = 'unmatched';

Deterministic matching runs in three passes, each progressively relaxing the match criteria:

Pass Match keys Typical hit rate
1: Full match Reference ID + amount + currency + date 80–90%
2: Key + amount Reference ID + amount + currency (date tolerance ±1 day) 5–10%
3: Amount + date Amount + currency + date (no reference ID) 2–5%

Pass 3 is dangerous — matching on amount and date alone produces false positives when multiple transactions share the same amount on the same day. Restrict Pass 3 to amounts above a uniqueness threshold for the given date, or flag matches for manual review.

After deterministic matching completes, the remaining unmatched entries — typically 2–8% of volume — fall to fuzzy matching or the exception queue.

Fuzzy Matching

Bank statements don’t speak your language. A payout you recorded as ref:PAY-2024-08-15-001 for $12,450.00 might appear on the bank statement as STRIPE PAYOUT 081524 12450.00 or ACH CREDIT STRIPE TECHNO 12,450. Fuzzy matching bridges this gap.

Three dimensions of fuzziness:

Amount tolerance. Processor fees, FX conversions, and bank charges create small discrepancies between what you sent and what arrived. A tolerance window absorbs these. ±$0.50 for domestic transactions, ±1% for cross-border (covering FX spread). The tolerance must be configurable per corridor and payment method.

Date windowing. ACH settlement takes 1–2 business days. Wire transfers settle same-day but post to statements the next business day. Cross-border SWIFT payments take 2–5 business days. Your date window must account for the settlement cycle of the payment rail.

Payment rail Settlement window Date tolerance
ACH T+1 to T+2 ±2 business days
Same-day ACH T+0 to T+1 ±1 business day
Wire (Fedwire) T+0 ±1 business day
SWIFT T+2 to T+5 ±5 business days
Card settlement T+1 to T+3 ±3 business days

Description parsing. Bank statement descriptions follow no standard. Extract candidate identifiers using regex patterns tuned to each bank’s format. Stripe payouts typically include STRIPE and a date fragment. ACH credits include the originator name. Parse what you can, score the match confidence, and set a threshold below which matches route to manual review.

def fuzzy_score(ledger_entry, bank_line):
    score = 0.0
    amount_diff = abs(ledger_entry.amount - bank_line.amount)
    if amount_diff == 0:
        score += 0.5
    elif amount_diff <= tolerance_for(ledger_entry.rail):
        score += 0.3
    else:
        return 0.0  # Amount too far off — not a match

    date_diff = business_days_between(ledger_entry.expected_settle, bank_line.date)
    if date_diff <= window_for(ledger_entry.rail):
        score += 0.3

    if extract_reference(bank_line.description) in ledger_entry.references:
        score += 0.2

    return score

A score above 0.8 is an auto-match. Between 0.5 and 0.8, flag for review. Below 0.5, no match — the entry stays in the exception queue.

Break Detection and Classification

A break is any entry that cannot be matched to its counterpart after all matching passes complete. Breaks are not errors — they are questions that need answers. The classification determines the urgency and the resolution path.

Break type Definition Typical cause SLA
Timing break Entry exists in one system, expected in another within the settlement window Normal settlement delay Auto-resolves within window
Amount break Matched by reference but amounts differ Fees, FX, partial settlement 24 hours
Missing entry Entry in external system with no internal counterpart Failed webhook, dropped event, manual bank transaction 4 hours
Orphan entry Entry in internal ledger with no external counterpart Processor rejection not recorded, void not propagated 4 hours
Duplicate Two entries match the same counterpart Retry without idempotency, double-posting 1 hour

Timing breaks are the most common and the least urgent. An ACH payment initiated today won’t appear on the bank statement until tomorrow. Your system should track expected settlement dates per payment rail and suppress timing break alerts until the settlement window expires.

Missing entries and orphans are the dangerous ones. A missing entry means money moved externally but your ledger doesn’t know about it. An orphan means your ledger thinks money moved but the external system disagrees. Both require immediate investigation.

A duplicate break is a potential double-spend or double-credit. If two ledger entries both claim the same bank statement line, one of them is wrong. Duplicate detection must run before amount matching — catching duplicates after they’ve been auto-matched means unwinding a completed reconciliation.

Exception Queues

Automated matching handles 92–98% of volume. The remaining 2–8% enters the exception queue — a prioritized work queue where operations analysts investigate and resolve breaks.

The exception queue is not a list. It’s a workflow engine with assignment, escalation, and audit trail.

Break detected → Classify → Route by type and amount
    ↓
Assign to analyst (round-robin within tier)
    ↓
Analyst investigates → one of:
    ├── Match manually (with justification)
    ├── Create adjusting entry (approved by supervisor if > $1,000)
    ├── Flag as expected (fee, FX, timing) with evidence
    └── Escalate (fraud, system error, unknown)
    ↓
Resolution recorded → Break closed → Audit trail preserved

Routing rules determine urgency:

Amount threshold Assignment Escalation deadline
< $100 Junior analyst 48 hours
$100 – $10,000 Senior analyst 24 hours
$10,000 – $100,000 Team lead 8 hours
> $100,000 Head of operations + finance 2 hours

Every resolution must include a reason_code (a controlled vocabulary — fee_deduction, fx_difference, timing, system_error, manual_adjustment, fraud_suspected) and a free-text justification. The reason code drives reporting. The justification satisfies auditors.

Breaks that age past their SLA generate alerts. Breaks that age past 2x their SLA escalate automatically. A break older than 30 days without resolution is a regulatory finding waiting to happen — most state money transmitter licenses require reconciliation within specific timeframes, typically 1–3 business days for customer funds.

Nostro/Vostro Reconciliation

Correspondent banking adds a layer. Your nostro account — the account you hold at another bank — has a balance you expect and a balance the correspondent reports. These must agree.

The expected balance is derived from your internal ledger: the sum of all entries booked to the nostro account. The actual balance comes from the correspondent’s statement (typically MT950 for intraday, MT940 for end-of-day, or camt.053 for ISO 20022 reporters).

Expected position = Opening balance (agreed)
                  + Debits you initiated
                  - Credits you initiated
                  ± Items in transit

Actual position   = Correspondent's reported balance

Break = Expected - Actual

Items in transit are the primary source of nostro breaks. You sent a SWIFT payment at 4:30 PM; the correspondent processes it at 9:00 AM the next day in their timezone. For that overnight window, your expected position includes the debit but the correspondent’s balance does not. This is a legitimate timing break — but you must track it explicitly to distinguish “payment in transit” from “payment lost.”

Nostro reconciliation operates on positions, not individual transactions. The goal is not to match every entry one-to-one (though large-value items should be matched individually). The goal is to explain the position difference — the gap between what you expect the balance to be and what the correspondent says it is. Every component of that gap must have a name: fees, FX revaluation, items in transit, rejected items, or a genuine discrepancy.

See The Ledger. A nostro account is just another account in your ledger. The reconciliation question is the same — does the derived balance match reality? The difference is that “reality” comes from an external correspondent, not from your own database.

Continuous vs Batch Reconciliation

Nightly batch reconciliation is a delayed alarm. If a payment fails at 9:00 AM and reconciliation runs at midnight, you have a 15-hour window where your ledger is wrong and nobody knows. For a customer-facing fintech processing real-time payments, that window is unacceptable.

Continuous reconciliation processes matches as events arrive, not at the end of the day.

Approach Latency to detect break Throughput Operational cost
Nightly batch Up to 24 hours High (bulk SQL) Low — runs once
Hourly micro-batch Up to 1 hour High Low-moderate
Streaming (event-driven) Seconds to minutes Moderate per-event Higher — always running

The streaming approach uses change data capture (CDC) or webhook events as triggers. When a processor event arrives, the reconciliation engine immediately attempts to match it against unmatched ledger entries. When a bank statement file lands, it’s parsed and matched within minutes.

Processor webhook → Parse event → Attempt match against ledger
    ├── Match found → Mark both sides reconciled
    └── No match → Start settlement timer
                    └── Timer expires → Break created → Exception queue

The settlement timer is critical. A processor event with no ledger match is not immediately a break — the ledger entry might post seconds later due to processing order. The timer waits for the settlement window (configurable per rail and event type) before declaring a break. Without the timer, you generate thousands of false-positive breaks from normal processing latency.

In practice, most teams land on hourly micro-batch as the right tradeoff. It detects breaks within an hour — fast enough for regulatory purposes — while maintaining the throughput advantages of bulk matching. True streaming is reserved for high-value payment corridors where a 15-minute detection window matters.

Scale: Matching Millions of Entries

At 100,000 transactions per day, deterministic matching completes in seconds. At 10 million per day, naive cross-joins between unmatched ledger entries and bank statement lines become quadratic and blow past any reasonable SLA.

Three techniques keep matching linear:

Partition by date. Entries settle within a bounded window. An ACH entry from Monday will not match a bank statement line from three weeks ago. Partition unmatched entries into daily buckets and only match within the relevant window. A 5-day window means each bank statement line compares against 5 days of ledger entries, not the entire unmatched set.

Index on match keys. Build composite indexes on (currency, amount, date) and (processor_reference). The deterministic matching passes become index lookups instead of table scans.

CREATE INDEX idx_recon_match
  ON ledger_entries (currency, amount, booking_date)
  WHERE reconciliation_status = 'unmatched';

CREATE INDEX idx_recon_ref
  ON ledger_entries (processor_reference)
  WHERE reconciliation_status = 'unmatched';

Partial indexes on reconciliation_status = 'unmatched' keep the index small. As entries are reconciled, they leave the index automatically. A system processing 10 million entries per day with a 98% auto-match rate maintains a working set of roughly 200,000 unmatched entries — well within single-node Postgres capability.

Batch by counterparty. Don’t match all unmatched entries against all bank lines. Partition by bank account and processor. Stripe entries match against Stripe payout lines. Wire entries match against wire settlement lines. Each partition runs independently and can parallelize across workers.

Daily volume Matching strategy Expected matching time
< 100K Single-pass, no partitioning < 10 seconds
100K – 1M Date-partitioned, indexed < 2 minutes
1M – 10M Date + counterparty partitioned, parallel workers < 15 minutes
> 10M Streaming with micro-batch fallback Continuous

The Reconciliation Report

Auditors don’t ask “is everything reconciled?” They ask “show me everything that isn’t, how long it’s been open, and what you’re doing about it.”

The reconciliation report is a daily artifact — generated automatically, reviewed by operations, archived for audit. It contains:

Summary metrics:

Metric What it measures Red threshold
Match rate % of entries auto-matched < 92%
Open break count Unresolved breaks > 50 over 3 days old
Open break value Total $ of unresolved breaks > $10,000
Aged breaks Breaks older than SLA Any
Mean time to resolution Average break resolution time > 24 hours

Break aging schedule: Every open break listed with its age, type, amount, assignment, and current status. Auditors focus on the tail — the breaks that have been open the longest. A single break open for 60 days will draw more scrutiny than 500 breaks resolved in 4 hours.

Resolution audit trail: Every closed break with its resolution reason code, the analyst who resolved it, the supervisor who approved adjustments, and the timestamp chain from detection to closure. The trail must be immutable — if an analyst changes the resolution reason, both the old and new reasons are recorded.

The report is not a dashboard. Dashboards show current state. The report is a historical record that proves your reconciliation process is functioning. When an examiner asks “how do you know your books are accurate?” the answer is the unbroken chain of daily reconciliation reports showing breaks detected, investigated, and resolved within SLA.