LedgerMap research · 17/10/2026 · for Khush

Scanned bank statements: how LedgerMap can read them

Today the app refuses every scanned PDF. I tested 11 ways to read scans on real Indian statements, scored every row against known values, and checked what our own validation would catch. This page gives the answer first, then the evidence.

An example with made-up figures. One model read a 6 as a 0 in a real test. The balance before, minus the debit, must equal the balance after. When it does not, we know which row to show the user.

The short answer

  1. What works: a vision-language model (VLM) that reads each page image straight into rows (date, narration, debit, credit, balance). Our own code then checks every row. The best models read 97–99% of rows exactly on real scans. With the checks, almost no wrong row got through unflagged.
  2. Where: on our server, not on the customer's PC. Local models on a normal PC CPU take 30–130 s per page. Our server has 4 CPU cores and no GPU, so it should send the page to an API, or to a rented GPU later.
  3. Which service: start with Gemini 3.8 Flash (97.9% of rows exact on real scans, about $4 per 1,000 pages). Add Claude Haiku 5.5 as a cheap second reader (97.2%, under $1 per 1,000 pages). Claude Opus 5.5 was the most accurate (99.3%), at about $26 per 1,000 pages. Use it for pages that fail the checks.
  4. Open source: see the local-model results below. PP-OCR (Apache-2.0) reads 99.8% of amounts exactly on CPU, so it is our independent second reader. It also gives the position of each value on the page.
  5. What does not work: adding OCR text to our current parser. Only 1 of 37 OCR'd statements validated. Tesseract misses 22–69% of amounts.

What I tested on

There were no scanned uploads in production yet (12 statements in total, all text PDFs). So I built three test sets. Two of them have exact answers.

SetWhere it came fromPagesRowsHow we know the right answerSent to cloud?
ProductionThe 6 distinct validated production statements, turned into scan-like images (tilt, blur, noise, grey paper, JPEG)22441The validated rows saved in productionNo, this PC only
31 banksOne text PDF per bank from the Drive samples (SBI, HDFC, ICICI, Axis, BoB, PNB, Kotak, co-operative banks and more), made scan-like in the same way31704Our parser's validated rows from the original text PDFYes
Real scansThe 31 scanned PDFs in the Drive samples: phone photos, office scans, passbook pages, handwriting, ticks, punched holes, cut-off edges124 (31 scored)428 scoredPage 1 of each PDF: where two models from different vendors agreed exactly and the balances reconciled (26 pages), I accepted the rows. I checked the other 5 pages by eye.Yes

A row counts as right only if its date, debit, credit and balance all match exactly. Narration is scored separately. Because the real-scan answers came partly from model agreement, they favour models that agree with Gemini and Claude Sonnet. The two other sets have no such bias.

Results

Rows read exactly right (date, debit, credit, balance)
EngineTypeReal scans31 banksProductionSeconds per pageUSD per 1,000 pagesLicense / access
Cloud APIs (tested through their command-line tools)
Claude Opus 5.5General VLM
99.3%
100%not sent17*~26Anthropic API; India processing on Bedrock
GPT-6 AstraGeneral VLM
98.8%
99.9%not sent28*~54OpenAI API; no India processing
Gemini 3.8 Flash (low thinking)General VLM
97.9%
100%not sent29*~4Google API; Vertex AI Mumbai (check model list)
Claude Haiku 5.5General VLM
97.2%
98.6%not sent20*~0.6Anthropic API
Gemini 3.8 Flash (high thinking)General VLM
86.2%
100%not sent109*~4One 46-row page came back empty
Claude Sonnet 5.5General VLM
91.8%
100%not sent15*~13Slipped a date column by one row on one page
Classic OCR on CPU (text only, no rows)
PP-OCRv6 via RapidOCRDetector + recognizer—amounts found exactly: 99.8%2–7 (4 cores)0Apache-2.0; runs on Windows and Linux
Tesseract 5Detector + recognizer—amounts found: 78% / 31%2–40Apache-2.0
PP-OCR words fed into our current parserOCR + recipes—58%57%+10Validated 1 of 37 statements

* Cloud times include the start-up of each command-line tool (10–20 s). A direct API call should take about 5–15 s per page, and pages of one statement can run at the same time. Costs are my estimate from the measured output size (about 820 output tokens per page) and the vendors' October 2026 prices. "Production" was run only with engines on this PC.

The mistakes the models made

All errors on the real scans fall into five kinds. These are the cases our checks must handle.

What our checks catch

For each page I ran four checks that need no right answer:

  1. The running balance reconciles on every row (in page order or newest-first).
  2. Every amount the model read also appears in the PP-OCR reading of the same page.
  3. The dates never go backwards.
  4. Every row has a balance and exactly one of debit or credit, and no "?".
Real scans, page 1 of 31 PDFs (26 pages with transactions)
ModelWrong rowsWrong rows that passed all checksAmounts flagged by PP-OCRWrong amounts the PP-OCR check caught
Claude Opus 5.530——
GPT-6 Astra5041 of 839 (4.9%)3 of 3
Gemini 3.8 Flash91 (the punched-hole date)45 of 847 (5.3%)7 of 8
Claude Haiku 5.512037 of 847 (4.4%)—
Claude Sonnet 5.536038 of 847 (4.5%)0 of 8 (the invented paise)

On whole statements the balance check is even stronger. Gemini 3.8 Flash read all 124 pages of the 31 real scans (2,155 rows). 27 of 31 statements reconciled from the first row to the last, across page breaks. The other 4 showed 45 breaks, all on pages with heavy handwriting or ticks. Those statements would open as "to check", with the exact rows marked, as the app already does for needs_review.

The PP-OCR check is strict: on real scans it questions about 5% of correct amounts, because PP-OCR itself misreads some cells. So it should mark single cells to check, not reject the page.

Where to run it

OptionSpeed (measured)AccuracyCostVerdict
Customer's Windows PC, CPUPaddleOCR-VL-1.6: 32 s/page; GLM-OCR: 128 s/page (4 fast cores). A 10-page statement takes 5–20 minutes. Office PCs are slower.Small open models onlyFree, but a 1–2 GB download in the installerNo for reading rows. PP-OCR (about 2–7 s/page) could run here.
Customer's PC, GPUFast on a gaming GPU, but most office PCs have noneSame as the open modelsFreeNo: we cannot count on the hardware
Our server (4 vCPU EPYC 7F72, 15 GB, no GPU)About 1.5–2× slower than my PC's cores: about 60 s/page for a 0.9B modelSame as the open modelsFreeOnly PP-OCR: a VLM is too slow for the 100 s Cloudflare limit
Our server → VLM API5–15 s per page; pages run in parallel97–99% of rows exact on real scans$1–26 per 1,000 pagesYes, start here
Our server → rented GPU with an open modelSee local GPU resultsSee local GPU resultsAbout $0.5–1 per hour for an L4-class GPU, or per-second serverlessLater: when volume is high or data must stay with us

At today's volume an API is far cheaper than any GPU. 10,000 scanned pages a month cost about $40 with Gemini Flash plus about $6 for a Haiku second reading. A GPU that is always on costs $400–700 a month.

Proposed design

The design keeps the rule that already protects the app: a statement is validated only when its numbers prove themselves, and otherwise it is needs_review with the rows marked.

1. UploadThe desktop app sends the PDF as it does now. The parser says scanned_pdf instead of failing.
2. RenderThe server renders each page at 200–300 DPI with PDFium.
3. Read twiceA VLM API reads each page into rows (all pages in parallel). PP-OCR reads the same page on our CPU and gives word positions.
4. CheckBalance chain across all pages, opening and closing balance, PP-OCR agreement per amount, dates in order, no "?". Rows that fail go to a stronger model (Opus) once.
5. ShowAll pass: validated. Else needs_review with the rows to check, and Alt+O shows the place on the page from the PP-OCR boxes.

Steps to build it

  1. Ask the user (the accountant) for consent to send page images to the chosen provider. Today they consent only to sending extracted text to OpenAI.
  2. Add a scan path on the server: render pages, call Gemini 3.8 Flash per page with a fixed JSON schema and amounts as strings, at temperature 0.
  3. Run PP-OCR (RapidOCR, ONNX) on the server for the cross-check and the word boxes.
  4. Reuse our validation: balance chain, totals, needs_review reasons, plus new reasons such as ocr_disagrees and unreadable_value.
  5. Retry failed rows once with Claude Opus 5.5 (or Haiku as a cheap second reader), then show the rest to the user.
  6. Make long statements asynchronous (a job with progress), because 20+ pages can pass Cloudflare's 100 s limit.
  7. Track every scan outcome in tracking_events, so we can measure real accuracy.

External services

From web research on 17/10/2026. I tested only the general VLMs above; the rest are from the vendors' pages.

ServiceWhat it givesUSD per 1,000 pagesProcessing in IndiaFit for us
Gemini 3.8 Flash (Google)Rows straight from the image (tested)~4 (doubles on 01/01/2027)Vertex AI Mumbai for listed models; check 3.8First choice
Claude Haiku / Opus 5.5 (Anthropic)Rows straight from the image (tested)~0.6 / ~26Amazon Bedrock India profileSecond reader / hard pages
GPT-6 Astra (OpenAI)Rows (tested); most honest about unreadable values~54Storage only, no processingToo costly
Mistral OCR 4 / 4.1Markdown and tables, block boxes, word confidence4 (2 in batch)Not statedNot tested: no API key
Google Document AI OCRWords with positions~1.5MumbaiSecond reader option
AWS Textract (tables)Words and table cells with positions15MumbaiSecond reader option
Azure Document Intelligence (Layout)Words and tables with positions~10Central IndiaIts bank-statement model reads US statements only
Perfios, FinBox, Signzy, HyperVerge, Arya.aiBank statement analysis for lenders; accept scansQuote onlyIndiaNo: built for credit reports, not rows for accountants
LlamaParse, Reducto, Unstructured, Nanonets, DocsumoGeneral document parsing1–300Not statedNo advantage over a VLM plus our checks

Open-source models

The newest public ranking (OmniDocBench v1.6, September 2026) is led by small open models. None of the benchmarks uses Indian bank statements, so I ran the ones that fit an 8 GB GPU on our sets.

ModelSizeLicenseOmniDocBench v1.6Positions of valuesNotes
TeleOCR1.2BApache-2.096.9Layout blocksBuilt for photos of documents
OvisOCR20.8BApache-2.096.5Images only
PaddleOCR-VL-1.60.9BApache-2.096.3Text spottingBest on skew and photos (Real5 benchmark)
MinerU2.5-Pro1.2BApache-2.0 (model)95.8Layout blocksReady CPU install in MinerU 4
GLM-OCR0.9BMIT95.2No
Chandra 24BPaid above $2M revenue—Layout blocksBest on old scans (olmOCR-Bench); not tested
Qwen3-VL 4B / 8B4–8BApache-2.0—On requestGeneral model: follows our JSON prompt
PP-OCRv5 / v6smallApache-2.0—Words and charactersNot generative: cannot invent a row

Privacy

Limits of this study

How I ran the tests

All work is in the main checkout's ignored folder .local/ocr-research/ (scripts, sets, results). scripts/build_sets.py makes the sets, run_cli.py runs cloud models, run_vllm.py and run_openai_api.py run local ones, evaluate.py scores rows, gate.py measures the checks, doc_check.py checks whole statements, and probe_docs.py feeds PP-OCR words into the current parser. The earlier experiment in bank-parser/docs/ocr-sample-036-benchmark.md reached the same result for OCR plus the current parser.

Every model got the same prompt: return rows as JSON, copy digits exactly, never calculate, write "?" for unreadable characters, and ignore handwriting and stamps. Document models got their own table prompt, and I turned their tables into rows by their column headers.

Sources