Back Menu

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 repair

The 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 -> blank

New 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.