Skip to main content
Moneyline
All articles
Deep Dive4 min read

Building a bank statement parser that beats AI models by 17%

A frontier model reads a bank statement about as well as a careful intern. Here is where that breaks, and what we built instead.

The Moneyline team · Engineering
View as .md
A seedling sprouting from a pile of coins

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.

ApproachTransaction F1Balance reconcilesp50 latency
Moneyline parsers99.4%99.1%1.2s
Frontier multimodal model82.1%61.4%8.4s
Frontier model, page-chunked84.9%68.0%14.1s
Generic OCR plus heuristics71.6%40.2%3.0s
1,412 statements, 312 institutions, held out from all parser development. Higher is better.

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.

packages/parsers/bank-statement/reconcile.tstypescript
/**
 * 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.

ParsersBenchmarksAccuracy

Keep reading

Parse your first document

Everything in this article runs on the open-source core. Clone it, or start on the hosted API.