Cash Application Intelligence — CID & Matching Engine Reference
| Owner | Classification | Version | Effective | Next review | Status |
|---|---|---|---|---|---|
| Platform Engineering | Internal | 1.1 | 2026-09-04 | 2026-12-04 | Draft |
Purpose. The single reference for how cash application decides — customer identification (CID) and the four matching legs — across both execution substrates (batch/bulk and online/live). It documents the shared configuration contract, every rule, the confidence model, the operator vocabulary, and — honestly — where the two engines agree and where they do not. This supersedes the scattered CASHAPP-INTELLIGENCE.md / CASHAPP-RULE-PARITY.md working notes.
For operating the engine (how to trigger a run, prerequisites, confirming a run), see Run the Cash Application Engine. This document is the rule-level companion that guide defers to.
1. Two engines, one config
Cash application identifies and matches on two substrates, by design, for performance — not two products, but two implementations of one decision model:
| Batch / Bulk | Online / Live | |
|---|---|---|
| Trigger | Run Engine action / schedule (cashapplication workflow) | Smart ID on a single payment |
| Substrate | Set-based DuckDB SQL over the whole eligible population | Per-item Postgres, EntityResolutionService.suggest() |
| Latency | Minutes (full dataset) | Sub-second |
| CID implementation | Hand-written DuckDB SQL stages (activities.ts) | TypeScript rule handlers (entityresolution.service.ts) |
| Matching implementation | Generated SQL (matching-sql-generator.ts) | MatchingEngineService (shared predicates) |
| Learns aliases | ✅ writes back confirmed aliases | ❌ suggest-only |
| Invokes Layer-2 judge | ✅ on ambiguity | ❌ caller decides |
The shared contract. Both engines read the same source of truth:
finbase.matching_config— one row per engine leg (CASH-CID,CASH-CL-BANK,CASH-RA-BANK,CASH-BANK-RA,CASH-RL-INV). Holdsauto_match_threshold,suggest_threshold,source_entity/target_entity,max_candidates.finbase.matching_rule— one row per rule (code,tier,confidence_score,cardinality,conditions/metadata,execution_mode).cid-config.ts— the CID tuning constants, every one env-overridable.
Because the config is shared, thresholds and rule catalogs are common to both paths. But the implementations are two separate codebases, and their coverage of that catalog is not identical — see §6 Parity. Treat that section as the authoritative answer to "why did live and bulk disagree?"
Engine inventory (as configured)
| Config code | Flow | Rules | Auto | Suggest |
|---|---|---|---|---|
CASH-CID | bank item → customer | 18 | 85 | 50 |
CASH-CL-BANK | bank item → company ledger (invoice match, "MR") | 29 | 95 | 50 |
CASH-RA-BANK | bank item → remittance advice (Smart Link, "RR") | 12 | 95 | 50 |
CASH-BANK-RA | remittance advice → bank item (reverse link, "BR") | 8 | 95 | 50 |
CASH-RL-INV | remittance line → company ledger (line match, "LR") | 12 | 95 | 50 |
79 rules total. (The old "24 MR + 12 RR" note is stale — the invoice-match leg is now 29 rules.)
2. The CID engine
CID answers "whose money is this?" — resolving a bank item to a customer. 18 rules across 4 tiers, evaluated tier-by-tier; within a tier, rules run in parallel and their scores combine.
The 18 CID rules
| Tier | Code | Conf | Rule | exec_mode |
|---|---|---|---|---|
| 1 | CID-001 | 95 | Exact Name Match | always |
| 1 | CID-002 | 98 | Bank Account Match | always |
| 1 | CID-003 | 95 | Customer Number in Reference | always |
| 1 | CID-004 | 90 | Known Alias Lookup | always |
| 1 | CID-014 | 97 | Tax ID Match | always |
| 1 | CID-018 | 92 | Lockbox: invoice # → invoice → customer | always ¹ |
| 1 | CID-021 | 96 | Amount disambig: subsidiary with $-matching open AR | always ¹ |
| 2 | CID-005 | 88 | Invoice Number in Reference | always |
| 2 | CID-006 | 90 | Remittance Advice Match | always |
| 2 | CID-007 | 85 | PO Number in Reference | always |
| 3 | CID-008 | 75 | Amount to Open Invoice | always |
| 3 | CID-009 | 80 | Amount + Date Pattern | always |
| 3 | CID-010 | 82 | Recent Payer History | always |
| 3 | CID-015 | 70 | Address Match | always |
| 3 | CID-016 | 78 | ZIP + Amount Combo | always |
| 4 | CID-011 | 80 | Normalized Name Match | always |
| 4 | CID-012 | 72 | Token Overlap Match (fuzzy) | bulk_only ² |
| 4 | CID-013 | 70 | Email Domain Match | always |
¹ always in config, but the live engine ships no handler for CID-018 / CID-021 — by design, not oversight: both are set-based / join-heavy (lockbox invoice→invoice→customer resolution; scanning open AR across subsidiaries to disambiguate) — exactly the full-table scans the live path is engineered to avoid. They run batch-side only (see §6). ² bulk_only: the live handler exists but is skipped at runtime — token-overlap fuzzy is ~40 s/batch, too slow for a per-click path (entityresolution.service.ts:852).
Bulk-only extras (code-only, not in the rule catalog): the DuckDB engine adds CID-019 / CID-019-LIKE (match against Razor-enriched customername) and CID-022 (remittance-driven CID — resolve the payer from a linked remittance's invoice references). These have no matching_rule row and no live equivalent.
CID confidence model
Governed by cid-config.ts (all env-overridable):
| Constant | Default | Meaning |
|---|---|---|
SHORT_CIRCUIT_CONFIDENCE | 95 | A candidate ≥ this stops tier evaluation — no lower tier runs |
AMBIGUITY_GAP_THRESHOLD | 15 | Top-two candidates within this gap → ambiguous → route (Layer-2 in bulk) |
AMBIGUITY_CONFIDENCE_FLOOR | 50 | Below this, don't even treat as a suggestion |
AMOUNT_MATCH_MAX_CUSTOMERS | 3 | Amount-only rules abstain if > N customers share the amount (anti-noise) |
MULTI_RULE_BONUS | 10 | Per additional rule that independently hits the same candidate |
TOKEN_OVERLAP_MIN | 0.6 | Min Jaccard-style token overlap for CID-012 to fire |
FUZZY_MAX_DISTANCE | 3 | Max edit distance for fuzzy name rules |
CONTEXT_ENRICHMENT_LIMIT | 3 | Max context rows pulled to enrich a candidate |
Scoring flow: run tier 1 → if any candidate ≥ SHORT_CIRCUIT_CONFIDENCE, stop; else tier 2, 3, 4. A candidate hit by multiple rules gets base + (n−1) × MULTI_RULE_BONUS. Decision against the CASH-CID thresholds: ≥ 85 → auto-identify, ≥ 50 → suggest, < 50 → no call. If the top two are within AMBIGUITY_GAP_THRESHOLD, the result is ambiguous: bulk hands it to the Layer-2 judge; live returns the ranked suggestions and lets the caller decide.
3. The matching legs
Once the payer is known (or a remittance is in hand), matching answers "which open items does this money clear?" Four legs, all config-driven — both engines build their predicates from the same matching_rule rows, so these legs are genuinely unified.
CASH-CL-BANK — invoice matching ("MR", 29 rules)
Bank item → company ledger. The core clearing leg.
| Tier | Codes | Character |
|---|---|---|
| 1 (90–95) | MR-001…009, MR-028, MR-029 | Reference + amount — invoice/PO/cheque/receipt ref found in a narrative field with the amount agreeing. Highest trust. |
| 2 (70–95) | MR-010…016, MR-026, MR-027 | Customer + amount, incl. tolerance bands and adjusted amounts (bank charges, withholding tax, payment-terms discount). |
| 3 (50–60) | MR-017…020 | Fuzzy / weak — levenshtein ref, amount-only + date proximity, token overlap. |
| 4 (60–85) | MR-021…025 | Many-to-one / one-to-many — multiple refs in one narrative, invoice-sum = payment, one invoice across several payments. |
CASH-RA-BANK — Smart Link ("RR", 12 rules) & CASH-BANK-RA (reverse, "BR", 8 rules)
Link a remittance advice to the bank item that paid it (and the reverse). Same shape as MR but keyed on payment/document number, payer account, and customer identity, decaying to amount-only + date proximity at tier 3.
CASH-RL-INV — line matching ("LR", 12 rules)
Remittance advice line → company ledger. Resolves each remitted line to a specific open invoice: exact/ERP ref (tier 1) → normalized ref (tier 2) → fuzzy ref (tier 3) → amount+date (tier 4). Rules come in with-amount and no-amount-check variants so a reference-certain line still matches when the remitted amount is a partial or grouped figure.
Cardinality
matching_rule.cardinality drives join shape: 1:1 (hash join, one item ↔ one target), N:1 (many open items sum to one payment — sliding-window aggregation via sum_equals), 1:N (one invoice across several payments).
4. Operator vocabulary
Every matching rule is a set of conditions over these operators (matching-sql-generator.ts). The same vocabulary compiles to DuckDB SQL (bulk) and to MatchingEngineService predicates (live).
| Operator | Meaning |
|---|---|
equals | exact equality (after transforms) |
contains | substring (e.g. invoice ref inside a narrative) |
abs_equals | absolute-value equality (sign-agnostic amounts) |
within_tolerance | numeric within a band — percent, absolute, or mixed |
levenshtein | edit distance ≤ threshold (fuzzy reference) |
token_overlap | Jaccard-style token overlap ≥ threshold (fuzzy name) |
date_within | dates within N days |
field_adjusted | compare after adjusting one side (bank charges, WHT) |
terms_discount | amount agrees after applying a payment-terms discount |
sum_equals | aggregate of grouped rows equals target (the N:1 engine) |
is_null / is_not_null | presence checks |
Field transforms applied before comparison: lowercase, uppercase, trim, normalize_ref (strip -, ., spaces + lowercase), abs, strip_alpha (digits only), ltrim_zero / ltrim_zero_lower (drop leading zeros).
5. Decision, clearing & trace
- Thresholds are per-leg on
matching_config:auto_match_threshold,suggest_threshold. Above auto → apply (STP); between suggest and auto → propose for review; below suggest → no action. - STP / clearing. Clean, unambiguous, above-auto matches auto-clear. Ambiguous or below-auto route for approval. Conflicts — one open item claimed by multiple payments — are downgraded to suggestions in a dedicated conflict-detection pass so two payments never auto-clear the same invoice.
- Match states —
Auto-Matchedis notCleared. The engine drives a payment toMatched/Auto-Matched(a confident, ready-to-apply match). Posting the clearing entry (clearing transaction + journal + AR reduction) is a separate apply step — the bulk engine does not auto-post it. So "straight-through" in the KPI means matched (matchstatus ∈ {Matched, Cleared}), and a zero-touch payment typically rests atAuto-Matcheduntil applied. - N:1 and the auto threshold. Individual N:1 rules are low-confidence on their own (
MR-021multi-ref = 85,MR-022/MR-025= 70/60 — all below the 95 auto threshold). A genuine N:1 reaches auto by stacking: several N:1 rules hit the same combination and theMULTI_RULE_BONUSlifts the combined score to ~99. A bare amount-only N:1 (no reference) tops out at 85 → suggestion, not auto. - Remittance confirmation gates N:1 clearing. A reference/amount N:1 match with no remittance parks at
wf = Awaiting Remittance(conservative: don't straight-through a multi-invoice payment on amount alone). Linking a confirming remittance moves it toAuto-Matched. So a "zero-touch STP" N:1 needs both the invoice references and a remittance. - Remittance candidate filter + ambiguity. The payment→remittance matcher (
CASH-RA-BANK) only considers remittances withpaymentstatus ≠ 'Matched'(a consumed remittance silently disappears from candidates — checkpaymentstatus, not thestatuscolumn). When two candidates both score ≥ 95 within the ambiguity gap, the result is a multi-suggestion — the engine presents both and the analyst picks (e.g. the same payment remitted via two channels). - Trace. Every decision writes to
finbase.intelligencelog(per-rule hits, scores, the winning candidate, andcomboAlternativesfor N:1). This is what the UI match/CID chips read; it is the audit trail for "why this outcome". A DuckDB preload ofintelligencelogis required for line-level intel to populate. - Idempotency. Re-running is safe: bulk write-back is keyed so a second run reconciles rather than duplicates. Extraction ≠ matching — re-running one does not refresh the other.
6. Bulk vs live parity — the honest picture
The config layer is unified; the implementations are two codebases with deliberately different coverage — the live path is tuned for sub-second latency and therefore omits the set-based, scan-heavy rules that only make sense in a batch pass. This is the authoritative answer to "why did the queue and the UI disagree?" — and the answer is usually "as designed", not "a bug".
| Area | Status | Detail |
|---|---|---|
Config (thresholds, catalogs, cid-config) | ✅ Unified | Single source of truth; both engines read it |
| Matching legs (MR / RR / BR / LR) | ✅ Unified | Both compile the same matching_rule rows through the shared operator vocabulary |
| CID tiers 1–3 (minus 018/021) + CID-011, 013 | ✅ Aligned | Implemented in both, same thresholds |
| CID-018 (Lockbox), CID-021 (Amount-disambig) | ✅ Batch-side by design | Set-based / join-heavy (invoice→customer resolution; cross-subsidiary AR scan) — too expensive for the sub-second live path, so run batch-only. Config label is the only mismatch (says always, behaves bulk-only). |
| CID-012 (Token Overlap) | ✅ Batch-side by design | execution_mode = bulk_only; live handler exists but is skipped — fuzzy token overlap is ~40 s/batch, too slow per-click |
| CID-019 / 019-LIKE / 022 | ✅ Bulk-only extras | Razor-enriched name + remittance-driven CID; DuckDB stages with no catalog row and no live equivalent (batch-scoped by nature) |
| CID-012 confidence formula | 🟡 Latent | Live 70 + score×15, bulk 70 + score×30 — inconsistent, but moot because live never runs CID-012 |
| Alias learning | ⚠️ Bulk-only | Bulk writes back confirmed aliases; live is suggest-only |
| Layer-2 judge | ⚠️ Bulk-only | Bulk invokes it on ambiguity; live returns suggestions for the caller to judge |
Net: a bank item with a strong tier-1–3 signal resolves the same on both paths. Bulk resolves more — it also runs lockbox, amount-disambiguation, fuzzy token overlap, the remittance-driven and Razor-enriched rules, and it learns. So the correct expectation is not "identical", but "live is a fast, high-precision subset; bulk is the complete engine."
Validated against the demo scenarios (2026-09-04)
Both paths were run over the 6 CID demo scenarios (bank items 9001–9006). CID behaved as documented on 6 of 6, and the live-vs-bulk differences matched this model exactly:
| Item | Signal | Live (per-click) | Bulk (batch) |
|---|---|---|---|
| 9001 | name embedded in a wire narrative ("…METRO DISTRIBUTION LLC…") | suggest @75 (CID-004 alias-substring) | auto-identify (CID-001) |
| 9002 | alias "ACME CORP" in narrative | suggest @75 (CID-004) | suggest (CID-004) — agree |
| 9003 | customer # CUST005 in reference | auto @95 (CID-003) | identify + match (CID-003) |
| 9004 | amount $7,777 matches two subsidiaries | ambiguous → route (CID-008) | route, pending ID (CID-008) — agree |
| 9005–9006 | no usable signal | none | no candidate — agree |
The 9001 divergence is instructive: even CID-001, a rule both engines implement, is not semantically identical — bulk's SQL matches the customer name embedded in a longer narrative, while live's handler requires the narrative to equal the name and so falls through to CID-004 (suggest). This is the same "bulk resolves more" principle at the rule-implementation level, not just the rule-catalog level. It is worth aligning if per-click auto-identification of embedded names is desired online.
Open follow-ups (housekeeping, not correctness)
- Truth-in-config: mark CID-018 / CID-021 as
execution_mode = bulk_onlyso the catalog matches their intentional batch-only behaviour (they readalwaystoday, which misleads). - Bulk hardcodes the CID-012 token-overlap floor at
0.6; live reads the env-overridableTOKEN_OVERLAP_MIN. Align bulk to the constant for config parity. - Reconcile the CID-012 confidence formula (15 vs 30) even though it is currently moot, to avoid a surprise if CID-012 is ever promoted to
always.
7. Config knobs (where to tune)
| To change… | Edit |
|---|---|
| A rule's confidence, tier, or predicate | finbase.matching_rule (that leg's rows) |
| Auto / suggest thresholds for a leg | finbase.matching_config.{auto,suggest}_match_threshold |
| Whether a rule runs live, bulk, or off | matching_rule.metadata.execution_mode (always / bulk_only / off) |
| CID short-circuit, ambiguity gap, multi-rule bonus, token floor, etc. | cid-config.ts constants or their CID_* env vars |
Related
- Run the Cash Application Engine — operating guide
- Reconciliation Matching Engine — the recon leg (distinct engine)
- Email & Document Ingestion — how remittances arrive
Revision history
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-09-04 | Platform Engineering | Initial canonical reference — CID (18 rules + bulk-only extras), 4 matching legs (MR/RR/BR/LR, 79 rules total), operator vocabulary, confidence model, and a code-grounded bulk-vs-live parity matrix. Supersedes the CASHAPP-INTELLIGENCE.md / CASHAPP-RULE-PARITY.md working notes. |
| 1.1 | 2026-09-04 | Platform Engineering | §5 extended with match-state and remittance behaviors surfaced during demo-scenario validation: Auto-Matched ≠ Cleared (clearing is a separate apply step), N:1 reaches the auto threshold only by multi-rule stacking, remittance confirmation gates N:1 clearing (Awaiting Remittance → Auto-Matched), and the paymentstatus remittance-candidate filter + ambiguity → multi-suggestion. |