Cash and bank reconciliation is the single biggest close bottleneck: 20 to 50 hours a month matching bank statement lines to ledger open items, most of it still done by VLOOKUP on amount. Bank Rec Referee is a governed alternative. A deterministic four-rule cascade clears the pile on documentary evidence, the model is only allowed to argue for the residual with quoted citations, and a named human referees every call it makes. The whole run is hash-chained and replayable.
Synthetic month · 3,000 bank lines vs 3,400 open items · seeded generator + deterministic engine → identical matches and hashes on every run.
Industry surveys consistently put spreadsheet-based reconciliation above 90% of mid-market companies; the analysis brief behind this demo cites 94%. The mechanics are always the same. Export the bank statement CSV, export the GL cash account and the AR/AP open items, then VLOOKUP on amount and eyeball the rest. Xero's own community forum has carried years of top-voted requests for matching by invoice number instead of amount, which tells you exactly which key practitioners trust and exactly which key the tools give them.
Amount-only matching fails on the four most ordinary things a bank statement does. Split and bundled payments mean no single open item carries the right amount. Bank fees netted from receipts mean the amount is close but never equal. Duplicate amounts mean the first VLOOKUP hit is frequently the wrong customer's invoice. And short payments with deduction claims look like errors when they are actually messages. The residual pile gets chased by hand, small differences get plugged to suspense, and unreconciled items roll quietly into next month with no record of why any match was made.
The tempting move is to hand both files to a language model and ask it to reconcile. That inherits every weakness of the VLOOKUP workflow and adds new ones:
So the design question is allocation. Deterministic code should own everything a documentary key can decide. The model earns a narrow role at the edge, where remittance text is mangled and human-shaped reading genuinely helps, and even there it only proposes. In this demo the model layer is a deterministic stub, stated on screen, so the whole run reproduces exactly; the governance around it is what a real LLM would be caged in.
The cascade runs four rules in order of evidentiary strength: an exact invoice number quoted in the remittance text, a unique payment reference, payer plus amount plus a three-day date window with exactly one candidate, and finally a solver that finds two or three open items summing to the line within a five-cent tolerance. Every auto-match writes its rule ID, matched keys and both record IDs to the ledger. What survives all four rules goes to the referee queue, where the model may propose a pairing only by quoting the exact substring of the source record that supports it. A code check verifies each quote is literally present before the proposal is shown, and below a confidence threshold the model must abstain. Accepting a proposal is a named human's decision, one click, logged.
Bundled remittances are the classic residual-pile item: one bank line settles two or three invoices and no single open item matches. That is a subset-sum question, and a subset-sum question should never be answered by a language model. Here is the fourth rule from the shipped engine, capped at combinations of three to stay client-side fast, with the consume step that makes every match exclusive:
// R4: subset of 2-3 of the payer's open items sums to the line
bank.forEach(function (line) {
if (matched[line.i]) return; // rules run in order; strongest key wins
var pool = (byParty[line.p + "|" + sideOf(line)] || [])
.filter(function (it) { return open[it.i]; }); // only items still open
var target = Math.abs(line.a), n = pool.length, i, j, k;
for (i = 0; i < n; i++)
for (j = i + 1; j < n; j++) {
if (Math.abs(pool[i].a + pool[j].a - target) <= TOL) // TOL = 5 cents
return take(line, "R4", [pool[i], pool[j]], ["sum of 2"]);
for (k = j + 1; k < n; k++)
if (Math.abs(pool[i].a + pool[j].a + pool[k].a - target) <= TOL)
return take(line, "R4", [pool[i], pool[j], pool[k]], ["sum of 3"]);
}
});
// take() removes the items from the open pool and writes rule ID,
// matched keys and both record IDs to the hash-chained run ledger.
One click runs the cascade over the synthetic June statement. The waterfall clears 61.0% on invoice numbers quoted in remittance text, 18.0% on unique payment references, 9.0% on payer plus amount plus date, and 4.0% through the split solver. The 240-line residual then splits into four honest categories:
| Residual class | Lines | What the model does | Evidence it must quote | Who decides |
|---|---|---|---|---|
| Truncated invoice digits | 85 | Proposes the one open item whose document number ends in the quoted digits, amounts agreeing to the cent | the digit run, e.g. "241573" | S. Lim, AR accountant |
| Bank charges netted from the receipt | 40 | Proposes the invoice plus a difference booked to bank charges expense | "INV-0412xx" + "LESS BANK CHARGES 12.50" | S. Lim, AR accountant |
| Short-pay with a deduction code | 35 | Proposes applying the cash and routing the shortfall to the deductions claims queue under its code | the code, e.g. "DED-QTY" | S. Lim, AR accountant |
| No usable evidence | 80 | Abstains to the investigate queue | none exists, so none is claimed | stays open, visibly |
The short-pay class matters more than its 35 lines suggest. A customer who pays S$14,300 against a S$15,000 invoice with "DED-QTY" in the remittance is filing a quantity claim. The old workflow plugs the S$700 to suspense and loses the message. Here the disposition is explicit: the cash is applied, the shortfall goes to the claims queue under its deduction code, and nothing is written off without a named decision.
The computed results, from the engine, every run: 92.0% of lines cleared by deterministic rules alone (1,830 + 540 + 270 + 120 of 3,000). The model touched only the 8.0% residual, proposed on 160 lines with verified quotes, and abstained on a third of it (80 of 240). Zero model-proposed matches are booked without a named human accepting them, which is enforced by the interface rather than promised by a policy. Every decision replays to identical hashes.
The limits, stated flatly. This is synthetic data, so real-world bank feed mess (encoding, MT940 dialect variants, PSP settlement batching) is only sampled, not covered. The split solver is capped at combinations of three to stay fast in the browser. Nothing posts to a real GL, so "reconciled" is a demo state. Match rates on real data depend heavily on remittance text quality, which varies by bank and by country; 92% is what this constructed month yields, a demonstration of the mechanism rather than a benchmark. And because I author both the data and the rules, the defensible claim is the architecture: documentary keys first, a propose-only model under a literal-quote check, a human gate, and a ledger that can replay the month.
Click once and watch the 3,000-line pile get eaten rule by rule. Open any residual line to see the model's quoted evidence highlighted in both records, and accept or reject it as the named referee. Flip the amount-only toggle to see the 222 wrong bookings the rules refused. Then tamper with the ledger and watch the chain locate the exact entry.
The design rule underneath this demo comes from years of reviewing other people's reconciliations: a junior who says "these match, trust me" gets sent back to the desk, and a junior who says "these match because the remittance quotes invoice 241573 and the amounts agree to the cent" gets a tick. I hold the model to the junior's standard. It may only argue from quoted evidence, the quote is verified in code before anyone sees it, silence is an acceptable answer, and the sign-off is a named person's. That standard is centuries older than language models, which is exactly why it survives them. If your close still runs on amount-matching, the cascade is the easy win; the governance around the leftovers is where the audit trail gets decided.