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¶
- Upload a photo via the web UI, or POST to
/ingest/email(used by the mailbot receipt job) - EXIF rotation correction applied immediately
- OCR runs in the background, roughly 30 to 45 seconds
- Extracted: merchant, total, date, line items
- Ollama
llama3.2:3bon big-boi categorizes each line item - 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
cardkeyword inskip_keywordswas 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 = NULLand came from an older pipeline that handledx2suffixes 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 turn2x Stick & Puck-Adult Ice TimeintoTransaction 400001and drop$1 Clothing x10from quantity 10 to 1 raw_linepreserves 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.
\bis not enough: a hyphen is a word boundary, so997895 KS OIL OW-20was recorded as twenty units of997895 KS OIL OW-at $2.00. Quantities above 100 are treated as product codes