---
title: "Receipt OCR Pipeline"
summary: "A phone scan of a stack of receipts becomes checked rows in a sheet."
date: 2026-06-06
links: []
---

<!-- DRAFT: Axel to rewrite -->

## 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

```mermaid
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:

<div class="mock" data-mock="receipt">

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

</div>

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

```mermaid
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]
```

```python
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.

```python
# 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.
