The obvious way to parse a bank statement in 2026 is to hand the PDF to a multimodal model and ask for JSON. It works surprisingly well, right up to the point where it does not, and the failure mode is the problem: the model does not know it got it wrong.
Where general models fail
Across our evaluation corpus, frontier models handle single-page, well-printed statements with clean transaction tables at high accuracy. Four situations degrade them sharply:
- Page-spanning tables. A transaction table that continues across a page break loses its header. Models routinely re-read the continuation as a new table and drop or duplicate the boundary rows.
- Unsigned negatives. Some institutions print debits without a minus sign and disambiguate by column. A model reading the number alone gets the sign wrong, and the sign is the entire meaning.
- Multi-account statements. One PDF, three accounts, three balance sequences. Models tend to flatten them into one ledger.
- Long documents. At 40 pages and 900 transactions, output quality falls off well before the context limit does. Rows go missing from the middle.
The benchmark
We measure transaction-level F1: a transaction counts as correct only when date, description, amount, and sign all match. Partial credit hides exactly the failures that matter downstream.
| Approach | Transaction F1 | Balance reconciles | p50 latency |
|---|---|---|---|
| Moneyline parsers | 99.4% | 99.1% | 1.2s |
| Frontier multimodal model | 82.1% | 61.4% | 8.4s |
| Frontier model, page-chunked | 84.9% | 68.0% | 14.1s |
| Generic OCR plus heuristics | 71.6% | 40.2% | 3.0s |
The 17% headline is the transaction F1 gap against the better model configuration. The column that actually changed how we build is the third one.
Reconciliation as a correctness oracle
A bank statement carries its own checksum. Opening balance plus the sum of transactions must equal closing balance. That identity is printed on the document, which means a parser can grade itself on every single input with no labels and no human.
/**
* The statement asserts its own arithmetic, so we check it rather than
* trusting extraction. A drift of one cent is rounding; anything larger
* means a row was missed, duplicated, or read with the wrong sign.
*/
export function reconcile(statement: ParsedStatement): Reconciliation {
const summed = statement.transactions.reduce(
(total, t) => total + t.amountMinor,
statement.openingBalanceMinor,
);
const drift = summed - statement.closingBalanceMinor;
if (Math.abs(drift) <= 1) return { ok: true, driftMinor: drift };
return {
ok: false,
driftMinor: drift,
// A drift that equals twice some transaction's amount is the
// signature of a sign error on that transaction, not a missing row.
likelyCause: statement.transactions.some(
(t) => t.amountMinor * 2 === drift,
)
? 'sign_error'
: 'missing_or_duplicate_row',
};
}This is why the balance column matters more than the F1 column. A parser that reconciles 99.1% of the time tells you which 0.9% to look at. A model at 68% is wrong about whether it is wrong, and every output needs a human.
Layout first, text second
The design follows from the failure list. We reconstruct the page geometry before reading any text: detect column bands by x-position clustering, detect table continuation by matching band geometry across pages, and only then attach glyphs to cells. Header text is a hint, never the source of truth, because a third of statements label their columns unhelpfully or not at all.
The model was not failing at reading. It was failing at bookkeeping, and bookkeeping has rules you can check.
Where models still win
Two places, and we use them there. Transaction descriptions are unstructured merchant strings, and a model normalizes SQ *BLUE BOTTLE 4471 to a merchant far better than a rule set. And for a layout no parser has seen, a model produces a usable draft where our parser correctly produces nothing.
So the pipeline is not parser-versus-model. Deterministic extraction handles structure and arithmetic, where being checkable matters. Models handle language, where it does not. The 17% comes from refusing to use one for the other.
