hsa-app/spec.md
Jean-Michel Tremblay e723e7989d Store receipt files under year/MM_DD_dollars.cents naming
Replace flat UUID filenames with a browsable, dated layout (spec.md item 9):
<STORAGE_DIR>/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>, from the receipt date
and amount. Same date+amount collisions get a _1, _2, … suffix via exclusive
create (race-safe). New uploads only; serving is unaffected (blob-backed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:10:41 -04:00

12 KiB
Raw Blame History

HSA Receipt Tracker — Requirements (v1) Purpose Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting. Users

Two users, both with full access (shared visibility). Authentication via Authelia OIDC. Authorization via membership in an Authelia group (e.g. hsa-users). Not in the group → 403. No other roles or gradations.

Core flow

User opens app on phone (mobile-first UI; camera access matters). Takes a photo of a receipt, or selects an existing image / PDF from device. Form prompts for: amount, date, category. Submit → image stored to disk, metadata row inserted into DB. Confirmation page, with options to view list or add another.

Data model receipts table:

id (UUID) uploaded_by (Authelia username or email) uploaded_at (server timestamp) receipt_date (user-supplied date on the receipt) amount_cents (integer — never store money as float) category (enum) file_path (relative path on disk — the filesystem copy) image_data (BLOB — the receipt file bytes, also stored in the DB itself) file_size_bytes (integer — convenience for listings/exports) original_filename (preserved for reference) mime_type deleted_at (nullable — soft delete)

Categories (fixed list, hardcoded for v1):

Medical Dental Vision Pharmacy Other

Storage

Receipt images/PDFs are stored in BOTH places on upload:

  1. Filesystem at a configurable path, filename randomized (UUID) on save (file_path) — used as the primary path for serving.
  2. As a BLOB inside the SQLite database (image_data column) — so the single .db file is a complete, self-contained dataset (metadata + files).

Rationale: the filesystem copy keeps serving simple/efficient; the DB blob makes backup and export trivial ("hand over one file" gets everything, even without the files directory). Written once at upload; no edit in v1, so the two copies never diverge. Acceptable cost because scale is tiny (two users, small files). original_filename and mime_type kept as metadata for download/serving. Backups remain JM's responsibility outside the app.

Auth integration

OIDC with PKCE against https://auth.jmopines.com. Session cookie after successful callback. /login, /callback, /logout, /healthz are public; everything else requires a valid session. Group claim (hsa-users) gates access; otherwise 403.

Deployment

Runs as a systemd service in an LXC. Caddy reverse proxy at https://hsa.jmopines.com (TBD: maisym.com vs jmopines.com). SQLite DB + filesystem storage. No external dependencies (no Redis, no Postgres, no S3).

Operations

Soft delete supported (set deleted_at, hide from default list views). No edit functionality in v1 — fix mistakes by deleting and re-adding.

Database export

Authenticated users (hsa-users group) can download the data for offline use.

Two endpoints:

GET /export/db — downloads a consistent copy of the SQLite database file. Because images are stored as BLOBs in the DB, this single file IS the complete dataset (metadata + all receipt images). This is the primary export.

  • Must NOT serve the live DB file directly (avoids locking/corruption against the running app). Use SQLite's online backup API (or VACUUM INTO a temp file) to produce a point-in-time snapshot, then stream that.
  • Content-Type: application/octet-stream; filename like hsa-export-YYYY-MM-DD.db.

GET /export/archive (optional convenience) — downloads a zip with the image files extracted to normal files (named by original_filename) plus a CSV/JSON of the metadata, for someone who wants the pictures as browseable files rather than inside a DB.

  • Streamed zip to avoid buffering large archives in memory.
  • filename like hsa-export-YYYY-MM-DD.zip.

Notes:

  • Amounts remain integer cents in the export; consumers divide by 100 for dollars.
  • Read-only operation; no app state is mutated.

Out of scope for v1

OCR / image content parsing Reports, totals, dashboards CSV / tax-software export Reimbursement tracking (paid vs pending status) In-place editing of existing receipts Multi-tenancy or per-user data isolation Notification / reminders

================================================================================ v2 — additions

These supersede the v1 "out of scope" entries for OCR-assisted entry (now present via the classifier) and "Reports, totals, dashboards" (see Tally below). Same two users, same auth model, same storage. Mobile-first still applies.

  1. Skip-AI toggle on upload

A control on the upload form lets the user opt out of AI parsing for the current receipt — for receipts they know are too hard to read, or to avoid spending an API call on a bad result.

  • Default: AI parsing ON (auto-fill runs when a file is attached).
  • When "skip AI" is selected, NO /classify call is made; the user fills amount, date, category, and who by hand.
  • The model footnote stays visible but reads as disabled when skip is selected, so the user always knows whether a call will happen and which model it uses.
  • Skipping is per-upload, not a saved preference.
  1. Duplicate-transaction warning before insert

Once a receipt has both a date and a dollar amount (whether typed or AI-filled), check the DB for an already-posted receipt that looks like the same transaction, and make the user confirm before inserting a possible duplicate.

  • Match: same receipt_date AND same amount_cents, among non-soft-deleted rows. When a "who" is set on both, prefer/highlight a same-person match; a match with a different or absent person is still shown as a weaker warning.
  • On a match, show the existing receipt's details: date, amount, category, who, uploaded_by, uploaded_at, original_filename (and a link to view it).
  • User chooses: Cancel (abort — nothing inserted) or Approve (insert anyway, as a deliberate duplicate). No new schema; this is a pre-insert read + confirm step.
  • No match → insert proceeds as today with no extra prompt.
  1. Tally tab

A totals view (read-only). Bucketed by the YEAR of receipt_date.

  • Matrix: one row per person, one column per year that has data; each cell is the summed amount for that person in that year.
  • Include an "Unassigned" row for receipts with no "who".
  • Right margin column: grand total per year (all persons).
  • Bottom margin row: grand total per person across all years.
  • Bottom-right cell: overall grand total tracked.
  • Excludes soft-deleted rows. Amounts shown in dollars (cents / 100).
  1. Recent uploads tab (by upload date)

A list of the most recently ADDED receipts, ordered by uploaded_at descending.

  • Show the 10 most recent, with a "Load next 10" control that pages further back (offset or cursor based).
  • Each row: receipt_date, amount, category, who, original_filename, and a link to view/download. Excludes soft-deleted rows.
  1. Recent receipts tab (by receipt date)

Identical to #4 but ordered by receipt_date descending instead of uploaded_at — "newest receipts" rather than "newest uploads". Same 10 + "Load next 10" paging.

  1. People catalog integrity (bug)

The Manage page currently shows partial-name duplicates (e.g. both "Jude" and "Jude Tremblay"). Only the canonical full names seeded from config.json ("First Last", per Person.Label) should exist as people.

  • Remove stray partial entries; keep only the config-seeded canonical labels.
  • Before deleting a partial entry, reassign any receipts that point at it to the matching canonical person so no receipt loses its "who".
  • Seeding must be idempotent: re-seeding from config.json must not create a second row for a person who already exists under the canonical label.
  1. Per-parse cost shown in cents (¢)

After a receipt is classified, show the cost of that single AI call, in cents, using the cent sign (e.g. "0.3¢"). The cost is computed locally from the API response — no extra API call needed.

  • The Messages API response includes a usage object (input_tokens, output_tokens, and cache_creation/cache_read token counts). The classifier should capture these and return them alongside the suggestion.
  • Cost = input_tokens × input_price + output_tokens × output_price, using the active model's per-token rates. For the default model (Haiku 4.5): $1 per 1M input tokens and $5 per 1M output tokens — i.e. $0.000001/input-token and $0.000005/output-token. Cache-read tokens bill at ~0.1× input; treat them at the input rate unless we add exact cache pricing later.
  • Display in cents with the ¢ sign next to where the model footnote shows the model name, so the user sees both which model ran and what the scan cost.

Where the rates come from (this is the only maintenance cost of the feature):

  • Token counts are exact and free — they come straight from the response's usage object, no estimation.
  • Per-token PRICES are not available from any API (the Models API exposes capabilities, not dollars), so they live as hardcoded constants in a small rate table keyed by exact model id: haiku-4-5 → $1/1M in, $5/1M out opus-4-8 → $5/1M in, $25/1M out sonnet-4-6 → $3/1M in, $15/1M out
  • This table is low-maintenance: Anthropic prices a specific model id once and ships price changes as NEW model ids, so an existing id's rate does not move. A new row is only needed when we adopt a new model — i.e. exactly when we'd be changing CLASSIFY_MODEL anyway.
  • Unknown model id (not in the table) → show the token counts but omit the ¢ figure (or "cost: n/a"), never a guessed number. A stale table degrades gracefully instead of lying.

(Balance/spend indicator: dropped. The Anthropic API has no remaining-balance endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is the only cost surface.)

  1. Human-readable on-disk file layout

Replace the flat UUID filenames with a dated, amount-tagged layout under the storage root, so the files directory is browsable on its own:

<STORAGE_DIR>/<YYYY>/<MM>_<DD>_<dollars>.<cents>.<ext>
  • Year folder and MM/DD come from the RECEIPT date (not upload date); dollars and cents come from amount_cents (cents zero-padded to two digits, dollars not padded). Example: a $42.50 JPEG dated 2026-06-08 → 2026/06_08_42.50.jpeg.
  • The amount's decimal dot and the extension dot coexist fine — the extension is just the final dot-segment ("jpeg"); the stem is "06_08_42.50". (If that ever feels ambiguous, the accepted alternatives are an underscore "06_08_42_50" or bare cents "06_08_4250" — pick one and keep it consistent.)
  • All path components derive only from the date and amount (digits, underscores, one dot), never from the user-supplied original filename, so there is no path- traversal surface. original_filename stays as metadata in the DB.
  • Collisions (same date + amount + ext — legitimately possible since duplicates can be approved) get a numeric suffix on the STEM, starting at _1: the second file becomes "06_08_42.50_1.jpeg", the third "_2", etc. Use exclusive create (O_CREATE|O_EXCL) and increment the suffix on "already exists" so two concurrent uploads can't race onto the same name.
  • The chosen relative path is stored in receipts.file_path as today; the dual write still also stores the bytes as the DB blob, and serving continues to work from the blob regardless of the on-disk name.
  • Applies to NEW uploads only — existing UUID-named files keep their file_path; no backfill/rename of historical files in scope.
  • saveFile must create the year subdirectory (MkdirAll) before writing.