Receipt OCR Pipeline
A phone scan of a stack of receipts becomes checked rows in a sheet.
Problem
Paper receipts have to become rows in a shared spreadsheet. Manual entry is slow. OCR is fast, but it misreads digits, and a misread total looks valid. The same receipt is sometimes scanned twice.
Approach
The friction point was intake, so I chose Google Drive's quick scan for bulk PDF import. The model only transcribes the receipt. The receipt's own arithmetic validates the transcription. A reading that fails validation is flagged for review, never corrected.
Solution
flowchart LR
PH[phone scan, Drive quick scan] --> IN[(Inbox)]
IN -->|split, one page per receipt| SP[(Split)]
SP --> R[read the page]
R --> C{gallons times price equals total?}
C --> W[append a row with a Scan link and a Review reason]
W --> MV[move to Past]
MV --> PA[(Past)]
IN --> E1[email: got N receipts]
PA --> E2[email: counts, red rows, receipts with no number]Drive folders act as the queue. The pipeline splits each scanned PDF into one page per receipt. Each page name includes the source file's ID, so a repeated split creates no duplicates. Moving a page from Split to Past marks it done. A page left in Split is retried on the next run. The pipeline polls every 15 minutes, one run at a time.
Validation checks that gallons times price equals the total, within 5 cents or 0.5%. One fuel receipt showed 26.320 gallons at $5.699 for $150.00. The OCR returned 26,328.0 gallons and $158.88. The same wrong total appeared on six receipts whose real total was $150.00.
The check on the real receipt, its misread, and a row you can edit:
| Reading | Gallons | Price | Total | Gallons times price | Review |
|---|---|---|---|---|---|
| The receipt | 26.320 | 5.699 | 150.00 | 150.00 | adds up |
| The misread | 26,328.0 | 5.699 | 158.88 | 150,043.27 | does not add up: check the Scan |
| Yours | 10.000 | 3.499 | 34.99 | 34.99 | adds up |
Numbers from the real receipt. The check is the real one.
A free model reads each page first. A paid model reads the page only when validation fails. If no reading passes, the pipeline keeps the first one and flags it.
flowchart TB
P[page image, 1000 px wide] --> M1[free model]
M1 --> K1{adds up?}
K1 -->|yes| OK[accept]
K1 -->|no| M2[cheap model]
M2 --> K2{adds up?}
K2 -->|yes| OK
K2 -->|no| M3[second cheap model]
M3 --> K3{adds up?}
K3 -->|yes| OK
K3 -->|no| FL[keep the first reading, Review: does not add up]def adds_up(r, abs_tol=0.05, rel_tol=0.005):
if None in (r.gallons, r.price, r.total): return None # cannot judge
return abs(r.gallons * r.price - r.total) <= max(abs_tol, rel_tol * r.total)
first = None
for model in LADDER: # free first
r = read(page, model)
first = first or r
if adds_up(r): return r, "" # spend stops here
return first, "does not add up: check the Scan" # flag, never repairThe pipeline reads the sheet's header row to locate each column, so people can add and reorder columns. Each row has a Scan link to its page and a Review column stating why it needs checking.
# header-driven: the sheet's own header row decides where each field goes
header = sheet.row(1)
row = [value_for(h, receipt) for h in header] # unknown header -> blankNew receipts are scored against existing rows to detect duplicates. A likely duplicate is highlighted red, never deleted. The pipeline emails a receipt count and a list of flagged rows. It runs on a self-hosted Windmill instance.
Outcome
| Measure | Value |
|---|---|
| First verification | 20 of 20 receipts |
| Check tolerance | 5 cents or 0.5% |
| Model cost, per million tokens | free, $0.03, $0.05 |
| Image width | capped at 1,000 px |
| Older rows now fixable | 22 dated before 2025, 11 with no date, 7 with no merchant, 2 with no total, each with a Scan link |
Receipts per month and the error rate before and after were not measured.
Applicability
The pipeline fits any paper form that must become spreadsheet rows, such as invoices, delivery slips and timesheets. When a document contains its own arithmetic, that arithmetic validates the OCR at no cost.