DEV Community

Cover image for Every bank statement carries its own answer key
Victor Isaac
Victor Isaac

Posted on AI-assisted

Every bank statement carries its own answer key

Built: 18 to 21 Oct 2026.
Status: live at ithamar.vercel.app. Next up: real statements from specific banks, then scanned PDFs.

Bank statement extractors get rows wrong quietly. A 7 read as a 1, a debit dropped into the credit column, one row lost at a page break. Nobody notices until the totals stop adding up, and by then the bad numbers are already in a spreadsheet, a loan file or a tax return.

The thing is, every bank statement already carries its own answer key. Each row obeys one line of arithmetic:

balance above + credit - debit = balance printed
Enter fullscreen mode Exit fullscreen mode

So I built Ithamar, which reads an Indian bank statement PDF, walks that equation down the page, and marks every row as one of three things:

Mark Meaning
✓ proven the arithmetic holds exactly as printed
↻ repaired it holds after one correction, and the correction is logged beside the row
✗ left for you no correction is earned; Ithamar says what it found and stops

It never guesses, and it never invents a transaction.

An audited statement and its seal

Parsing without templates

Most extractors keep a template per bank. I wanted one parser that learns each statement's layout from the page itself.

parse/layout.py finds the header row, learns where each column sits from its header words, then assigns every word below it by geometry: amounts align to a column's right edge, text to its left. A line without a date continues the row above, which is how wrapped narrations survive. It handles lakh grouping (1,00,000.00), Dr/Cr suffixes, single amount columns with a separate direction column, and headers repeated on every page.

And one rule that saved me hours of debugging later: all money is integer paise. Floats never touch an amount.

When a row breaks

If the equation fails, the naive move is to stop, or to "fix" the balance so everything below lines up again. Both are wrong. Stopping hides every later row. Forcing the balance hides the actual error.

Instead, the chain resyncs on the printed balance, so one bad cell makes one or two breaks rather than corrupting everything below it. Then each break is tested against four concrete explanations, each with the exact correction it implies:

  1. The amount is in the wrong column.
  2. A digit in the amount was misread: one substitution, drop, insertion or adjacent swap, or a decimal shift.
  3. A digit in the balance was misread. Its row breaks, and so does the row below.
  4. A whole row is missing.

Repair is a decision, not a guess

This is the part I am proudest of. A correction is applied only when both of these hold:

  • it is the only specific explanation that closes the local window: the broken row and the next row that prints a balance must both agree afterwards
  • it strictly reduces the number of breaks

And a missing row is never applied. "There was a transaction here that I can't see" explains any break, which means it proves nothing. So Ithamar flags it for a person and leaves it alone.

There is one more subtle rule. A row the engine corrects ends as repaired, never proven. "Proven after a change" is a different claim from "proven as printed", and the output should never blur the two.

The metric that matters

A benchmark that rewards fixing things will happily reward wrong fixes. So the headline number is the opposite: false-verified rows, rows marked proven that are actually wrong. It must stay at zero.

ithamar bench renders statements whose truth is known by construction, injects extraction-style errors into what is printed (never into the truth), and scores the engine:

24 statements, 855 rows, 39 injected errors
every swap, amount misread and balance misread repaired to its true value
every missing row flagged and left alone
false-verified rows across 24 statements: 0
Enter fullscreen mode Exit fullscreen mode

To be clear about what that shows: these are synthetic statements from the project's own generator. They show the verifier works. They do not show that the parser handles every real bank. Real statements are the next test, and I will publish those results the same way, failures included.

The test suite leans on property tests with Hypothesis. The headline property is that whatever single error is injected, across hundreds of generated ledgers, no row is ever marked proven or repaired unless its values equal the truth.

Two engines, one behaviour

The command line runs a Python engine on pdfplumber. The website runs a TypeScript port on pdf.js, so a statement is parsed and proven entirely inside your browser tab. Nothing is uploaded, and there is no server.

Two engines invite drift, so a parity script runs both on the same PDFs and fails on any difference in rows, amounts, statuses, repairs, unresolved rows or decoded narration fields. CI runs it on every push.

Each audited statement also gets a seal: a guilloche rosette, like the security printing on a cheque, drawn from that statement's own balances. Every row owns a slice of it. Proven rows draw clean, repaired rows draw in violet, and a row that does not balance prints out of register in magenta, exactly where it broke. It is the most fun I have had making a correctness check visible.

What the chain proves, and what it doesn't

Proven by arithmetic Not proven
Debit, credit and balance of every verified row Dates
No transaction is missing between two verified rows Narration text and counterparty names
The closing balance matches the last row Rows on statements that print no running balance

Narrations are still decoded (UPI, NEFT, IMPS, RTGS, VPA, RRN, UTR, IFSC and a best-effort counterparty), and the output says plainly that none of that is proven by the chain.

Why the name

Exodus 38:21 records the first audit: the materials of the tabernacle were counted "by the hand of Ithamar". And its numbers still check. Exodus 38:25 to 26 counts 603,550 men giving half a shekel each, which is 301,775 shekels. It separately reports the total as 100 talents and 1,775 shekels, which at 3,000 shekels to the talent is also 301,775. Two independent records, one total. That is exactly what this tool does to a bank statement.

Try it

pip install git+https://github.com/victorisaacchinta-eng/ithamar
ithamar demo        # generate synthetic statements and audit them
Enter fullscreen mode Exit fullscreen mode

ithamar audit exits 0 when every row is proven, 1 when rows were repaired, and 2 when a person needs to look, so it can gate a script.

If you have run into a statement layout that breaks parsers, tell me in the comments. That is exactly the test data I need next.


I wrote this with AI assistance, working from the repo. Every number above comes from the repo's benchmark.

Top comments (0)