# receiptsnap

Point it at receipt photos or PDFs and get validated expenses in a CSV, a JSON file or a ledger-style journal. One call to Claude Haiku 5.5 reads each receipt (merchant, date, line items, GST/VAT breakdown, tip, total, payment method); everything else (cleanup, duplicate detection, arithmetic checks, categorisation, export) is deterministic Python, so it stays cheap and you can trust what lands in your books. Anything that does not add up goes to a review queue instead of your ledger.

## Features

- Image preprocessing: EXIF orientation, grayscale + autolevel, border crop, downscale to 1568 px. PDFs are rendered from the first page (`pypdfium2`).
- Perceptual-hash duplicate detection (a re-photographed receipt is never sent twice) and an on-disk response cache keyed by sha256.
- Structured output through a Pydantic schema: items, taxes (CGST/SGST/IGST with rates), tip, total, confidence.
- Local validation: items vs subtotal, subtotal + taxes + tip vs total, CGST = SGST, tax rate vs amount, date sanity, currency inference from symbols (₹ $ € £).
- Categorisation from a TOML rules file first; the model's suggestion is only a fallback from the same call.
- Exports with idempotent IDs: re-running never duplicates a row. The journal output is accepted by [ledgerline](../ledgerline).
- `review` for fixing flagged receipts in the terminal, `watch` for a drop folder, `--dry-run`, `--verbose` token and cost report.

## Install

```bash
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate
export ANTHROPIC_API_KEY=sk-ant-...     # not needed for --dry-run or --fixtures
```

## Quickstart (no API key needed)

```bash
receiptsnap scan examples/receipts --fixtures examples/receipts \
    --rules examples/rules.toml --out expenses.csv --journal main.journal
```

`--fixtures DIR` reads the recorded model response `<image stem>.json` instead of calling the API. With a real key, drop `--fixtures`.

## Usage

### Dry run

Shows exactly what would be sent, with an input token estimate, and touches nothing.

```
$ receiptsnap scan examples/receipts/fuel.png --dry-run
examples/receipts/fuel.png dry-run
image 636x480 (30 KiB jpeg), ~547 input tokens
model: claude-haiku-5-5
system: You extract structured data from a single receipt or bill image. Copy numbers exactly as
printed; never invent line items. ... confidence (0 to 1) reflects how legible and complete the receipt is.
user: [image] Extract this receipt.

Dry run: nothing was sent. Estimated input tokens: 547
```

### Scan

```
$ receiptsnap scan examples/receipts --fixtures examples/receipts --rules examples/rules.toml \
      --out expenses.csv --journal main.journal --verbose
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ File               ┃ Status    ┃ Merchant           ┃ Date       ┃        Total ┃ Account            ┃
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ cafe_usd.png       │ added     │ Blue Door Cafe     │ 2026-10-12 │    28.14 USD │ Expenses:Food:Din… │
│ fuel.png           │ added     │ Bharat Petroleum   │ 2026-10-09 │ 3,382.19 INR │ Expenses:Transpor… │
│ grocery.png        │ added     │ Green Basket       │ 2026-10-07 │ 1,193.50 INR │ Expenses:Food:Gro… │
│                    │           │ Supermart          │            │              │                    │
│ grocery_retake.jpg │ duplicate │ looks like         │            │              │                    │
│                    │           │ grocery.png        │            │              │                    │
│ parking_flagged.p… │ review    │ City Centre        │ 2026-10-14 │   267.80 INR │ Expenses:Transpor… │
│ restaurant_gst.png │ added     │ Spice Route        │ 2026-10-03 │ 1,260.00 INR │ Expenses:Food:Din… │
└────────────────────┴───────────┴────────────────────┴────────────┴──────────────┴────────────────────┘
needs review parking_flagged.png: subtotal + taxes + tip = 247.80 does not match total 267.80
tokens in=0 out=0 estimated cost=$0.000000 (5 cached/fixture)
4 added, 1 review, 1 duplicate
```

Running the same command again reports `4 exists` and adds no rows. The output is chosen by `--out` (`.csv` or `.json`; default `expenses.csv` when neither option is given) and `--journal`; both may be used together.

The journal (`main.journal`) is balanced double-entry text:

```
2026-10-03 Spice Route Kitchen  ; id: a720da98d6b9
    Expenses:Food:Dining  1,200.00 INR
    Expenses:Taxes:CGST   30.00 INR
    Expenses:Taxes:SGST   30.00 INR
    Assets:Bank:HDFC
```

Check it with ledgerline: `cat examples/accounts.journal main.journal > all.journal && ledgerline check -f all.journal` prints `OK: 5 transactions, 15 postings`.

### Review

Flagged receipts wait in `.receiptsnap/queue.json` together with the outputs they were destined for. `receiptsnap review` shows each one with its problems; choose accept, edit (merchant, date, currency, subtotal, tip, total, account; re-validated afterwards), skip (stays queued) or drop. Accepted rows go to the original `--out`/`--journal`.

```
$ receiptsnap review
   parking_flagged.png  [5d6a84db0695]
┌──────────┬────────────────────────────┐
│ merchant │ City Centre Parking        │
│ total    │ 267.8                      │
│ ...      │ ...                        │
└──────────┴────────────────────────────┘
  ! subtotal + taxes + tip = 247.80 does not match total 267.80
[a]ccept [e]dit [s]kip [d]rop [a/e/s/d] (e): ... total (267.8): 247.80 ...
1 accepted, 0 left in queue
```

### Watch

`receiptsnap watch ~/Receipts --journal main.journal --rules rules.toml` polls the folder (`--interval`, default 5 s) and processes new files; `--once` processes what is there and exits. It accepts the same options as `scan`.

### Rules

```toml
[defaults]
payment_account = "Assets:Bank:HDFC"   # when the payment method is unknown
tax_prefix      = "Expenses:Taxes"     # Expenses:Taxes:CGST, ...
tip_account     = "Expenses:Tips"
roundoff_account = "Expenses:RoundOff"

[payment]                               # payment_method -> funding account
cash = "Assets:Cash:Wallet"

[[rule]]                                # first match wins, case-insensitive regex on the merchant
match = "petroleum|shell"
account = "Expenses:Transport:Fuel"
```

No rule matched: the model's `suggested_category` is used if it looks like an account, else `Expenses:Uncategorized`. The accounts named in your rules are passed to the model as preferred suggestions.

## Configuration

| Option / env | Meaning |
| --- | --- |
| `ANTHROPIC_API_KEY` | credentials (or `ant auth login`); never read from a flag |
| `--model` / `RECEIPTSNAP_MODEL` | default `claude-haiku-5-5` |
| `--state-dir` / `RECEIPTSNAP_HOME` | cache, seen hashes, review queue; default `.receiptsnap` |
| `--force` | skip duplicate detection |
| `--fixtures DIR` | recorded responses instead of the API |

Cost: Haiku 5.5 at $0.10 / 1M input and $0.50 / 1M output tokens; a receipt is roughly 600 input tokens, so about $0.0001 each. `--verbose` prints the real usage. Responses are cached, so re-runs are free.

## Architecture

| Module | Role |
| --- | --- |
| `imaging.py` | load images/PDFs, preprocess, dHash, token estimate |
| `extract.py` | prompt, Haiku call via `messages.parse`, cache, fixtures, error mapping |
| `validate.py` | arithmetic, GST, date and currency checks |
| `rules.py` | TOML rules, category and payment accounts |
| `writers.py` | CSV / JSON / journal writers, idempotent by ID |
| `store.py` | state directory: seen hashes, review queue |
| `pipeline.py` | per-file flow tying the above together |
| `cli.py` | `scan`, `watch`, `review` |

See [docs/design.md](docs/design.md) for the algorithms.

## Tests

```bash
.venv/bin/python -m pytest
```

65 tests, all against a fake client (no network): preprocessing, dedupe, validation maths, rules, writers, idempotency, error mapping, and the CLI end to end.
`scripts/make_examples.py` regenerates the example receipts and recorded responses.

## Limitations

- The perceptual hash survives re-scaling, recompression and brightness changes but not rotation or heavy perspective skew.
- Only the first page of a PDF is read; one receipt per image.
- The recorded fixtures are hand-written, not real model output; live calls were not exercised in development (no API key).
- A flagged receipt that is fixed in `review` gets a new ID if the merchant, date or total changed, so re-scanning the original image queues it again.
- Duplicate detection compares against receipts already processed, not against pending dry runs.
