Design notes

Pipeline

For every file: load (PDF first page via pypdfium2) -> preprocess -> hash -> duplicate check -> extract (cache, fixture or API) -> validate -> categorise -> export or queue.

Preprocessing

  1. EXIF transpose so phone photos are upright.
  2. Flatten transparency to white, convert to grayscale, autocontrast(cutoff=1).
  3. Border crop: the background level is the median of the four corner pixels; pixels differing by more than 25 levels form a mask and its bounding box (plus 8 px) is kept. The photo of a receipt on a dark table is cut down to the paper. An all-background image is left alone.
  4. Downscale so the long side is at most 1568 px, then JPEG q85.

The processed JPEG is what is hashed, cached and sent, so cosmetic differences in the original do not matter.

Duplicate detection

A difference hash (dHash) on a 17x16 grid gives 256 bits. The image is Gaussian-blurred first (sigma = long side / 50): receipts are mostly white paper, and an unblurred 8x8 hash flipped 10 of 64 bits between a receipt and a scaled, recompressed copy of itself. Measured on the examples (256 bits):

pair Hamming distance
grocery vs its re-taken copy 13
grocery vs other receipts 49 to 61

Two images are duplicates when the distance is at most 25. A file whose own sha256 was already seen is not a duplicate: it is processed again through the cache (free) so you can export it to a new file.

Cache

Key: sha256 of [model, system prompt, sha256(processed jpeg)]. The system prompt includes the preferred accounts from the rules, so changing rules changes the key. Fixture responses bypass the cache.

Validation

Amounts use Decimal built from the string form of the float. Tolerance is max(1.00, 1% of total), which absorbs the rupee round-off printed on Indian bills.

Worked example, the flagged parking receipt: items 160 + 50 = 210 matches subtotal 210; GST 18% of 210 = 37.80 matches; but 210 + 37.80 + 0 = 247.80 differs from the total 267.80 by 20.00, above the tolerance of 2.68, so the receipt goes to the queue.

Other checks: CGST must equal SGST (within 0.02); IGST cannot coexist with CGST/SGST; a tax's rate times the subtotal must be within max(1.00, 5%) of its amount; date must parse, not be in the future, and be at most 730 days old; confidence below 0.5 is flagged. Currency is taken from the model's ISO code if known, else inferred from the printed symbol (Rs, ₹, $, €, £, ¥).

Idempotent IDs

id = sha256(lower(merchant) | date | currency | total)[:12]. CSV and JSON outputs are checked by the id field; the journal stores it as the transaction comment ; id: ... and checks for that text. The same receipt photographed twice yields the same ID even if it slipped past the image hash.

Journal postings

Category account receives the subtotal, each tax goes to <tax_prefix>:<Name>, tip to the tip account, and the funding account is left without an amount so it balances. A residual total - subtotal - taxes - tip within tolerance is posted to the round-off account, so each entry always sums to the printed total. If the model gave no subtotal it is derived from items, or from total minus taxes and tip.