B-08 · Run reconciliation matcher
SOP:
SOP_AI_Bookkeeping_Automation.md§5.2 (Matching Engine)Actors: Bookkeeper. Pre-state: ABankStatementinparseStatus = PARSED. At least oneAPPROVEDJournalEntryto match against. Post-state:ReconciliationRuninAWAITING_REVIEW.ReconciliationMatchrows for EXACT / PROPOSED matches.UnreconciledItemrows for no-match transactions.
The seed already creates a run for ZENITH in AWAITING_REVIEW with 1 EXACT + 2 PROPOSED matches and 3 unreconciled items. Use this flow to start a fresh run against a new statement.
0. Prerequisites
- Bank statement parsed (B-07).
- At least one
APPROVEDJournalEntry.
1. Steps
1.1 Start the run
Web: /dashboard/bookkeeping/reconciliations → click Start run, pick the statement.
Or via API:
POST /ops/bookkeeping/reconciliations
Authorization: Bearer <staff-jwt>
{
"statementId": "<statementId>",
"dateToleranceDays": 3
}The route:
- Verifies the statement is
PARSED. - Verifies no other open run exists for the same statement (
status NOT IN ('COMPLETE', 'FAILED')). - Persists
ReconciliationRunwithstatus = MATCHING,dateToleranceDays = 3(configurable per client; default 3 per SOP §5.2). - Enqueues
bookkeeping.run-matcher.
1.2 Worker runs the matcher
bookkeeping.run-matcher handler iterates each BankTransaction for the statement:
- Priority 1 — Exact match. Same client, same bank account, journal entry status =
APPROVED,txnDatematchesentryDate,debitAmount/creditAmountmatchestotalAmount(after FX conversion if currencies differ),referencematchesjournalEntry.reference(case-insensitive substring). Auto-confirmed. - Priority 2 — Proposed match. Same amount; date within
dateToleranceDayswindow. Reviewer must confirm. - Priority 3 — Amount-only proposed. Same amount; date outside the window. Reviewer must confirm. Rare — only fires when the priority-2 set is empty for the txn.
- No match. Creates
UnreconciledItemwithstatus = PENDING.
The handler writes ReconciliationMatch rows with matchType ∈ EXACT | PROPOSED, matchedBy = 'SYSTEM', confirmedAt = now() for EXACT only.
After processing every txn, transitions ReconciliationRun.status = AWAITING_REVIEW, stamps matchedAt.
1.3 Inspect the run
Web: /dashboard/bookkeeping/reconciliations/<runId>.
Expected ZENITH layout (from seed):
| Bank line | Match status |
|---|---|
1 — SP GROUP ELECTRICITY BILL | ✅ EXACT (auto-confirmed) → INV-SPG-202603 |
2 — UBER *TRIP | 🔍 PROPOSED → UBER-20260408-0001 |
3 — FIGMA.COM USD 75 @ 1.35 | 🔍 PROPOSED → FIGMA-SUB-202604 |
4 — IRAS GST PAYMENT | ❌ PENDING (journal entry is FLAGGED — engine refuses to auto-pair) |
5 — ATM WITHDRAWAL | ❌ CLIENT_RESPONDED (NO_SUPPORTING — pre-seeded for the exception queue demo) |
6 — CUSTOMER PAYMENT INV-24-001 | ❌ PENDING |
API:
GET /ops/bookkeeping/reconciliations/<runId>
GET /ops/bookkeeping/reconciliations/<runId>/matches
GET /ops/bookkeeping/reconciliations/<runId>/unreconciled2. Verification
Database
SELECT status, started_at, matched_at, completed_at FROM reconciliation_runs WHERE id = '<runId>';
SELECT match_type, COUNT(*) FROM reconciliation_matches WHERE run_id = '<runId>' GROUP BY 1;
SELECT status, COUNT(*) FROM unreconciled_items WHERE run_id = '<runId>' GROUP BY 1;Audit log
| Event | Notes |
|---|---|
bookkeeping.reconciliation.started | run created |
bookkeeping.reconciliation.match_created | per match |
bookkeeping.reconciliation.unreconciled_created | per unreconciled |
bookkeeping.reconciliation.matching_complete | run.status moves to AWAITING_REVIEW |
Worker logs
bookkeeping.run-matcher runId=<id> exact=1 proposed=2 unreconciled=33. Negative & edge cases
- Statement not parsed → 400
"Statement is not in PARSED state." - Open run already exists → 400 with the existing run id; use
POST /ops/bookkeeping/reconciliations/<id>/run-matcherto re-run instead. - Run-matcher idempotency — re-running on an
AWAITING_REVIEWrun preserves already-confirmed matches and only re-evaluatesUnreconciledItemrows. New EXACT/PROPOSED matches appear without touching prior confirmed matches. - Currency mismatch — bank line in SGD vs journal entry in USD: the matcher converts at
journalEntry.exchangeRateto compare. Tolerance 0.01 SGD. (ZENITH Figma seed:75 USD × 1.35 = 101.25 SGD, matches the bank debit exactly.) - Multiple journal entries match the same bank line — the matcher picks the highest-confidence (EXACT > PROPOSED) and writes a
bookkeeping.reconciliation.ambiguous_matchaudit event listing the alternatives. - Journal entry status
FLAGGEDorDRAFT— never auto-matched. Engine ignores them. ZENITH IRAS line lands inPENDINGfor exactly this reason.
Next
Proceed to B-09 · Confirm proposed matches.