Skip to content

B-08 · Run reconciliation matcher

SOP: SOP_AI_Bookkeeping_Automation.md §5.2 (Matching Engine)Actors: Bookkeeper. Pre-state: A BankStatement in parseStatus = PARSED. At least one APPROVED JournalEntry to match against. Post-state: ReconciliationRun in AWAITING_REVIEW. ReconciliationMatch rows for EXACT / PROPOSED matches. UnreconciledItem rows 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 APPROVED JournalEntry.

1. Steps

1.1 Start the run

Web: /dashboard/bookkeeping/reconciliations → click Start run, pick the statement.

Or via API:

http
POST /ops/bookkeeping/reconciliations
Authorization: Bearer <staff-jwt>

{
  "statementId": "<statementId>",
  "dateToleranceDays": 3
}

The route:

  1. Verifies the statement is PARSED.
  2. Verifies no other open run exists for the same statement (status NOT IN ('COMPLETE', 'FAILED')).
  3. Persists ReconciliationRun with status = MATCHING, dateToleranceDays = 3 (configurable per client; default 3 per SOP §5.2).
  4. Enqueues bookkeeping.run-matcher.

1.2 Worker runs the matcher

bookkeeping.run-matcher handler iterates each BankTransaction for the statement:

  1. Priority 1 — Exact match. Same client, same bank account, journal entry status = APPROVED, txnDate matches entryDate, debitAmount/creditAmount matches totalAmount (after FX conversion if currencies differ), reference matches journalEntry.reference (case-insensitive substring). Auto-confirmed.
  2. Priority 2 — Proposed match. Same amount; date within dateToleranceDays window. Reviewer must confirm.
  3. 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.
  4. No match. Creates UnreconciledItem with status = 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 lineMatch status
1 — SP GROUP ELECTRICITY BILLEXACT (auto-confirmed) → INV-SPG-202603
2 — UBER *TRIP🔍 PROPOSEDUBER-20260408-0001
3 — FIGMA.COM USD 75 @ 1.35🔍 PROPOSEDFIGMA-SUB-202604
4 — IRAS GST PAYMENTPENDING (journal entry is FLAGGED — engine refuses to auto-pair)
5 — ATM WITHDRAWALCLIENT_RESPONDED (NO_SUPPORTING — pre-seeded for the exception queue demo)
6 — CUSTOMER PAYMENT INV-24-001PENDING

API:

http
GET /ops/bookkeeping/reconciliations/<runId>
GET /ops/bookkeeping/reconciliations/<runId>/matches
GET /ops/bookkeeping/reconciliations/<runId>/unreconciled

2. Verification

Database

sql
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

EventNotes
bookkeeping.reconciliation.startedrun created
bookkeeping.reconciliation.match_createdper match
bookkeeping.reconciliation.unreconciled_createdper unreconciled
bookkeeping.reconciliation.matching_completerun.status moves to AWAITING_REVIEW

Worker logs

bookkeeping.run-matcher  runId=<id> exact=1 proposed=2 unreconciled=3

3. 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-matcher to re-run instead.
  • Run-matcher idempotency — re-running on an AWAITING_REVIEW run preserves already-confirmed matches and only re-evaluates UnreconciledItem rows. 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.exchangeRate to 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_match audit event listing the alternatives.
  • Journal entry status FLAGGED or DRAFT — never auto-matched. Engine ignores them. ZENITH IRAS line lands in PENDING for exactly this reason.

Next

Proceed to B-09 · Confirm proposed matches.

Internal use only — BreezyCorp