Skip to content

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_hit is False (so it can't stack with reference)
  • amount_exact (zero-cent diff, not 2-cent tolerance)
  • counterparty_hit fires
  • days_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 unmatched rows (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.