Scanned statements: a new step, “Check the scan”
Today LedgerMap refuses a scanned PDF. This mock-up shows how it could read one, check every row, and ask the accountant only about what the checks could not prove.
Everything here is invented: the bank, the party, the people and the amounts. The pages are drawn in HTML, not real scans.
Design notes
For Khush. Short, so they can be argued with.
Where the step goes
- After Upload, before Bank ledgers. A fix changes the rows themselves (a row added, two removed). Bank ledgers, In Miracle and every later step read those rows, so they must be right first.
- Only when needed. The step shows in the step bar only while a scan in the import has problems. A scan that proves itself goes straight to Bank ledgers, like a text PDF.
- One step for every scan. With two scans to check, the step shows one PDF at a time, with Previous and Next PDF, as Bank ledgers does.
- Continue anyway reuses what exists: the statement is saved as
needs_reviewwithreview_kept, and the Bank ledgers step shows its “Check this statement” panel with the rows marked on the page. - Older apps do not send the new query, so they get
422 scanned_pdfas today.
What is automatic, and what is not
- Fixed by itself (dashed green box): the change is proven. A Brought forward row is left out. A page that fails a check is read again by a second model, and its reading is used only if it passes every check and the second reader agrees.
- Needs you (amber box with a number): the checks cannot prove the value. A guess here is exactly the error we want to stop, so a suggestion is shown but never applied without a key press.
- Fixed by you (green box): a decision the user made. Each has Undo. The original reading stays on the server.
- The second model's re-read is shown on each problem, so the user knows that the page itself is unclear, not the reader.
What the server sends to the app
Part of the upload response for a scan that needs a check (and of GET /api/statements/{id}/review). Rows are indexes into statement.transactions. Boxes are fractions of the page, as the existing SourceBox, taken from PP-OCR's word positions. Amounts stay decimal strings.
{
"status": "needs_review",
"source": "scan",
"scan": {
"pages": 6, "readSeconds": 5.8,
"fixedByItself": [
{ "kind": "brought_forward_left_out", "page": 1, "amount": "52300.00", "box": { "page": 1, "x0": 0.02, "y0": 0.37, "x1": 0.99, "y1": 0.42 } },
{ "kind": "page_read_again", "page": 2, "reason": "dates_shifted" }
],
"problems": [
{ "id": "q1", "kind": "rows_missing", "pages": [1, 2], "afterRow": 6, "amount": "-388.00",
"between": ["2019-04-27", "2019-05-03"], "readAgain": "same", "boxes": [ … ] },
{ "id": "q2", "kind": "rows_swapped", "rows": [16, 17], "suggest": { "swap": [16, 17] }, "readAgain": "same", "boxes": [ … ] },
{ "id": "q3", "kind": "unreadable_value", "field": "date", "row": 22, "read": "?5/06/2019",
"choices": ["2019-06-05", "2019-06-15", "2019-06-25"], "readAgain": "same", "boxes": [ … ] },
{ "id": "q4", "kind": "ocr_disagrees", "field": "debit", "row": 31,
"readings": { "first": "6130.00", "second": "6136.00" }, "balanceNeeds": "6136.00", "boxes": [ … ] },
{ "id": "q5", "kind": "unreadable_value", "field": "balance", "row": 36, "read": "48210.??",
"workedOut": "48210.40", "alsoPrinted": { "page": 6, "row": 38 }, "readAgain": "same", "boxes": [ … ] },
{ "id": "q6", "kind": "repeated_rows", "rows": [38, 39], "repeat": [35, 36], "page": 6,
"broughtForward": "76509.90", "boxes": [ … ] }
]
}
}
The app sends its fixes back in one request, for example PUT /api/statements/{id}/scan-fixes with {"fixes":[{"problem":"q1","addRows":[{"date":"2019-04-29","description":"POS/MEDPLUS PHARMA","debit":"388.00"}]},{"problem":"q6","removeRows":[38,39]}],"keepReview":false}. The server runs the same checks again and answers validated, or needs_review with review_kept when the user chose Continue anyway. Each fix is recorded in tracking_events, without copying statement text, so we can measure how often each kind needs a person.
Open questions for you
- Consent for page images. Users agreed to send extracted text to OpenAI. A scan image also carries names, account numbers and signatures, and it goes to Anthropic. Ask once per user, once per party, or on the first scan? Do we want Bedrock's India region from day one?
- Leave the gap. When rows are lost at a page edge and the user does not have them, should LedgerMap book the missing ₹388.00 to Suspense as “Missing from the scan”, so the bank ledger still agrees with the statement? Or bring in nothing and only mark the row?
- Worked-out values. When the balance before and after prove a cut-off value (the paise example), should LedgerMap fill it by itself? This mock-up asks, because only one reader saw it.
- A step or a panel? This mock-up adds a step. The other choice is to put all of this in the Bank ledgers step's existing “Check this statement” panel. A step is clearer, but it is one more stop.
- Show both readings? For a misread digit, the step names a “first reading” and a “second reading”. Is that useful to an accountant, or should it show only the value that makes the balance add up?
- Long scans. Above about 20 pages, reading can pass Cloudflare's 100 s limit. Should the user go on with the other PDFs while a long scan is read in the background?
- Same scan again. Should the fixes stay with the PDF, so a later upload of the same file (same SHA-256) skips this step?
Synthetic data only. No customer data or real scans are used. Made for LedgerMap on 18/10/2026. Research behind it: ledgermap-ocr-scans.pages.dev.