Skip to content

Receipts Scanner

URL: https://receipts.heezy.info Internal: http://192.168.1.15:30850 k8s: heezy namespace, deployment receipts Source: heezy-containers/dockerfiles/receipts/ Last Updated: 2026-08-12


How It Works

  1. Upload a photo via the web UI, or POST to /ingest/email (used by the mailbot receipt job)
  2. EXIF rotation correction applied immediately
  3. OCR runs in the background, roughly 30 to 45 seconds
  4. Extracted: merchant, total, date, line items
  5. Ollama llama3.2:3b on big-boi categorizes each line item
  6. Results appear in the UI, editable

Current data: 171 receipts, 840 line items, $7,964.57 of line items.

Uploads are concurrent-safe: the row is written before OCR starts, OCR runs in a daemon thread per upload, and the browser polls /receipt/<id> until fields appear. The pod is given 3000m CPU so a burst of uploads runs its pipelines in parallel rather than sharing one core.


OCR Backends

Four, selected by the OCR_BACKEND env var. The app's code default is expense; the k8s deployment pins textract, and the pinned value wins.

Value Engine Cost Behaviour
expense Textract AnalyzeExpense $0.010 / page Typed receipt fields, no regex layer
textract Textract DetectDocumentText $0.0015 / page Raw lines, then regex extraction. Currently deployed
ollama Local vision model on big-boi free OLLAMA_VISION_MODEL, default gemma4:e4b
tesseract Local tesseract free Usually returns an empty string on a phone photo

OCR_ALLOW_TEXTRACT=true lets a non-textract backend fall back to Textract.

Why AnalyzeExpense is the code default but not the deployed value

The regex layer over raw text is the accuracy ceiling: on most receipts the item description and its price sit on separate lines, so line-item extraction produced nothing. AnalyzeExpense returns typed fields and fixes that, at roughly 7x the per-page cost. The deployment pins textract with a comment saying it is pinned so it does not silently follow a change to the app default. If line-item quality is the complaint, flipping this env var is the first thing to try.

Check the model exists before changing OLLAMA_VISION_MODEL

Ollama answers 404 for a model it does not have, which is what happened to every vision call while it pointed at qwen2.5vl:7b — never pulled on big-boi. gemma4:e4b is installed and reports the vision capability. Verify with curl http://192.168.1.21:11434/api/tags.


Database

Writes to receipts and receipt_items in the heezy Postgres database on big-boi.

Key columns on receipts:

Column Meaning
merchant Raw extracted merchant name
merchant_normalized Lowercased and alias-mapped
date Raw OCR date string, legacy, mixed formats
date_ts Parsed timestamptz. Canonical. Some rows are NULL and invisible to the dashboard
upload_date timestamptz. Was TEXT until 2026-08-12, see the warning below
total, items_total numeric
category Overall receipt category
payment_type cash / credit / debit / check / other, set manually
status, status_detail, processing_started_at Processing state machine
ocr_source Which backend produced the text
ocr_text, ocr_text_tesseract, ocr_text_easyocr Raw OCR output per engine
manual_fields Array of fields a human edited, so reprocessing does not clobber them
bank_transaction_id, reconciliation_status, unreconciled_reason Reconcile linkage
filename, thumbnail_filename Image on the NFS volume

receipt_items.quantity, unit_price and total_price are numeric too. init_db declared them REAL until 2026-08-12 while the live database had numeric, so a fresh install and production disagreed. Both are now numeric, with a guarded migration for older databases.

Money columns are NUMERIC, so psycopg2 returns Decimal

Flask's default JSON provider serialises Decimal as a quoted string, so the API returned {"total_price": "39.99"}. Anything calling .toFixed() on that throws. It surfaced as "Error loading items" on every receipt that had line items. NumericJSONProvider in app.py now renders Decimal as a number for every endpoint. Do not remove it, and do not add a route that bypasses it.

upload_date used to be TEXT with two incompatible formats

/upload wrote isoformat() (2026-08-11T17:45:30) and heezy-finance wrote NOW()::text (2026-08-11 23:50:45+00). ORDER BY on TEXT is lexicographic and a space sorts before T, so every finance-created receipt sank below every uploaded one from the same day and fell off the list window. The column is timestamptz now and both writers send real timestamps. If you add a third writer, send a timestamp, not a string.


Storage

Images live on the nfs-receipts PVC (100Gi, NFS, RWX) mounted at /data/receipts. There is no S3 in this path — the finance dashboard links images as https://receipts.heezy.info/image/<receipt_id>, served straight off the volume.

GET /receipts returns has_image per row, computed from one os.listdir of the volume rather than a stat() per row. The UI uses it to tell "never had a photo" apart from "the file is gone", instead of guessing from the filename.

Rows that legitimately have no image: heezy-finance's promote-a-transaction flow writes manual-<txn_id>, and /ingest/email writes email_<order>.txt. Both are sentinels with no file behind them, and the UI labels them "Manually entered" rather than showing a broken image.

An image with no database row is not proof of a lost receipt

Deleting a receipt used to drop the row and silently fail to remove the file, so an orphaned image is just as likely to be a receipt deleted on purpose, or a second photo of one already recorded.

On 2026-08-12 a recovery run imported all 52 orphans it found and created 36 duplicate receipts, including seventeen copies of one Costco receipt and thirteen of one INSHOP receipt. The duplicates were removed and reconcile_orphans.py now rolls back any import whose amount and date already exist. See Recovering orphaned images.


Recovering orphaned images

reconcile_orphans.py ships in the image. It finds original uploads (<uuid>.<ext> only, so _thumb, _preprocessed and _upscaled never match) that have no receipts row.

# dry run, writes nothing
kubectl exec -n heezy deploy/receipts -- python3 /app/reconcile_orphans.py

# recover, one Textract call per receipt
kubectl exec -n heezy deploy/receipts -- python3 /app/reconcile_orphans.py --apply

--no-ocr creates rows and leaves OCR to the reprocess button. --limit N for a cautious first pass. --allow-duplicates disables the duplicate guard, which is off by default for a reason.

The file's mtime becomes upload_date, since it is the only surviving record of when the receipt was uploaded. For images that arrived by a bulk copy rather than an upload, that timestamp is the copy time, not the original upload.

An import that turns out to duplicate an existing receipt is rolled back, but the image is left on disk deliberately: it really is an orphan, and deleting a file to resolve a guess is how data gets lost. Skipped images are offered again on the next run.


Credentials

Textract IAM user receipts-textract, defined in terraform-heezy. Keys reach the pod through an ExternalSecret from OpenBao production/heezy/receipts/aws-credentials. Postgres credentials come from production/heezy/postgres/heezy-credentials.


Notable Endpoints

Route Purpose
POST /upload Web UI upload
POST /ingest/email Mailbot receipt ingestion, see the heezy-mailbot skill
GET /receipts List, ?limit= (default 500, max 2000) and ?offset=. Returns has_image
GET /image/<id>, GET /thumb/<id> Serve the stored image
PATCH /receipt/<id> Edit receipt fields
DELETE /receipt/<id> Delete the receipt, its items and its image files
POST /receipt/<id>/item, PATCH/DELETE .../item/<item_id> Line item editing
POST /receipt/<id>/reprocess Force an OCR re-run on a stuck or failed receipt
GET /api/categories Canonical category list
POST /api/fuel-log, GET /api/vehicles Forwards fuel receipts to heezy-maintenance (MAINTENANCE_URL)
GET /health Liveness

GET /receipts was a hardcoded LIMIT 100 with no pagination until 2026-08-12. Past 100 rows the oldest receipts were unreachable from the UI with nothing indicating they existed.

DELETE /receipt/<id> is the only correct way to delete a receipt. It owns the NFS volume, so it removes the image alongside the row. Deleting the row from Postgres directly strands the image, and a stranded image is what the recovery tool mistakes for a lost receipt. It returns orphaned_files listing anything it could not remove, rather than reporting success and staying quiet.


Categories

CATEGORIES in app.py must match CATEGORY_ICONS in heezy-finance/categories.py. The two are separate containers so the list cannot be imported, and it is hand-maintained.

It drifted five behind — Alcohol, Transportation, Retirement Funds, Stock Funds and MittenTech were selectable in finance but not here, so an alcohol line item could not be categorised from the receipts UI at all. test_categories_match_heezy_finance now parses the sibling list and fails the build on any difference in either direction.

There is one taxonomy. receipts.CATEGORIES, heezy-finance.CATEGORIES and heezy-finance.ITEM_CATEGORIES are all the same 27 entries, so an item, a receipt, a bank transaction and a budget line share a vocabulary and consolidation never translates between lists.

ITEM_CATEGORIES was a narrower 18-entry list until 2026-08-12, excluding account-level concepts like Retirement Funds on the reasoning that nobody puts a mortgage in a shopping cart. That is true of a cart, but it is also the list the receipts dropdown had to agree with, and the two drifted: Alcohol reached the classifier and never reached the receipts UI. Both sides now carry a test that parses the other's list and fails the build on any difference.

What the wider list costs

The item classifier is now offered account-level categories, so a model answer of Mortgage for a widget will stick where the narrow list forced Other. normalize_category still rejects anything outside the taxonomy. If that misclassification shows up in practice, the fix is prompt guidance, not a second list.


Known Quirks

  • A bare card keyword in skip_keywords was silently dropping items like "SHARP CARD / HOCKEY". Narrowed to compound phrases (debit card, credit card)
  • The finance dashboard's Reconcile page uploads photos by POSTing directly to https://receipts.heezy.info/upload, bypassing the finance app
  • Line items predate several extractor versions. Roughly 126 of them have ocr_source = NULL and came from an older pipeline that handled x2 suffixes and parenthetical detail the current regex extractor does not. Do not bulk re-extract existing items — a 2026-08-12 dry run showed it would turn 2x Stick & Puck-Adult Ice Time into Transaction 400001 and drop $1 Clothing x10 from quantity 10 to 1
  • raw_line preserves the original OCR line and is the reliable source when repairing a description
  • A trailing number is only treated as a quantity when whitespace separates it. \b is not enough: a hyphen is a word boundary, so 997895 KS OIL OW-20 was recorded as twenty units of 997895 KS OIL OW- at $2.00. Quantities above 100 are treated as product codes