Matching
The matcher is the heart of the autonomous loop. Given a candidate
pair (BankMovement, InvoiceCandidate), it computes a confidence
score in [0, 1]. Above 0.85 = auto-accept; 0.55 ≤ score < 0.85 =
HITL prompt; below 0.55 = leave unmatched.
The four base signals
+0.55 reference_hit invoice number appears in the bank
reference text as a whole token
+0.25 amount_hit amounts equal within 2-cent tolerance
+0.15 date_proximity linear decay: full at days_off=0,
zero at days_off=21
+0.05 counterparty_hit token-set overlap on names ≥ 4 chars,
case-insensitive, diacritics folded
Sum is ≤ 1.0 by construction. Calibrated for an Estonian OÜ with a small handful of receipts per month; tune carefully if you change the thresholds.
The card-debit boost
Wise (and other card-issuing fintechs) never put the invoice number in the bank description. A card debit for Hetzner reads something like:
CARD-3976488961 Card transaction of 45.61 EUR issued by
Hetzner Online Gmbh HETZNER.COM/
So reference_hit is permanently zero for card debits. The four
base signals can only sum to ~0.45 on these — below HITL.
The card-debit boost adds +0.15 when:
reference_hitis False (so it can't stack with reference)amount_exact(zero-cent diff, not 2-cent tolerance)counterparty_hitfiresdays_off ≤ 2
This substitutes for the unreachable reference signal. Score for the Hetzner case: 0.25 + 0.143 + 0.05 + 0.15 = 0.59, clears HITL.
The boost is gated on not reference_hit so cases where the
reference IS present (bank transfers with payment memos) don't
inflate past 1.0. See domain/matching.py for the scoring code.
Two directions, one matcher
The same scoring runs in two flavours:
- Bank-side first: a new bank transaction lands; matcher walks
candidate documents (currency + ±21-day date window). Runs every
poll tick, including for existing
unmatchedrows (re-match). - Doc-side first: a new document is indexed; matcher walks candidate unmatched recons. Runs once per ingested document.
Both paths write the same end state (reconciliations.status =
'proposed' + document_id set). They cannot both promote the
same row because set_reconciliation_proposed is gated on the
unmatched precondition.
Indexed narrowing
For larger candidate pools (e.g. an Estonian OÜ with a long
backlog of receipts), the matcher includes a MatchIndex that
buckets candidates by invoice-number prefix and by amount bucket.
For 50 candidates × 30 movements, scoring drops from ~1500 calls
to ~150 — a 10× win. Ported from iseppo/e-arveldaja-mcp; see
ADR-037.