Compare commits

..

8 commits

Author SHA1 Message Date
Jean-Michel Tremblay
e94a17160b Add deploy docs (CICD + Authelia/OIDC); changelog 0.1.0
All checks were successful
Build and Test / build-and-test (push) Successful in 39s
deploy/CICD.md documents which git push/tag triggers which pipeline
steps; deploy/AUTH.md documents the Authelia OIDC integration contract
with a sequence diagram of the login flow. Cross-link from README and
INSTALL. The 0.1.0 release also ships the previously-committed security
hardening (server-side session expiry + nosniff on served files).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:20:08 -04:00
Jean-Michel Tremblay
866bc175fb Security hardening: server-side session expiry + nosniff on files
All checks were successful
Build and Test / build-and-test (push) Successful in 38s
1. Enforce the 12h session lifetime server-side in requireAuth (reject a
   session older than the TTL even if the sealed cookie is intact), so a
   leaked cookie value can't be replayed indefinitely. Shared sessionTTL
   const drives both the cookie MaxAge and the check.
2. Send X-Content-Type-Options: nosniff when serving user-uploaded
   receipt/attachment bytes, so the browser won't sniff past the declared
   (upload-time allowlisted) MIME type.

Update SPEC §2 and §6 accordingly; tests cover stale-session rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:56:38 -04:00
Jean-Michel Tremblay
c930f715b7 AI tab: full-width auto-growing note editors
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Replace the single-line note inputs (which truncated long notes) with
wrapping, auto-growing textareas and a per-note Save/Delete row, so the
whole correction note is readable and editable. Changelog 0.0.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:17:37 -04:00
Jean-Michel Tremblay
c715c8e0c0 AI classifier correction notes + misread review (AI tab)
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00
Jean-Michel Tremblay
da7fc56920 Add consolidated SPEC.md; demote spec.md to DESIGN.md journal
All checks were successful
Build and Test / build-and-test (push) Successful in 36s
SPEC.md is the new domain-organized source of truth for current behavior,
reconciled against the code (notes soft-delete/dup-highlight/zip-export as
not-yet-implemented). The old chronological spec.md becomes DESIGN.md, the
why/history journal. Docs only — no behavior change, no release tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:59:46 -04:00
Jean-Michel Tremblay
2ff5c3847c Optional receipt tags; changelog 0.0.2
All checks were successful
Build and Test / build-and-test (push) Successful in 36s
Add a shared, free-form tag vocabulary attachable to a receipt at upload.
New tags/receipt_tags tables (case-insensitive label dedup); an in-page
chip-mosaic picker after "Who" with inline tag creation. Tags are
resolved/created only on successful submit, and shown on the confirm
page and recent lists. Documented as spec item 14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:35:14 -04:00
Jean-Michel Tremblay
7ba0d5abe7 Normalize receipt image orientation from EXIF; add changelog (0.0.1)
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Bake the EXIF Orientation rotation into uploaded JPEG pixels (and strip
the tag) so receipts are upright in every consumer, not just EXIF-aware
viewers. Acts only when orientation is known (tag 2..8); images with no
tag, tag 1, non-JPEG, or PDFs pass through byte-for-byte. Wired into the
receipt, attachments, and AI-classify paths. Documented as spec item 13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:26:03 -04:00
Jean-Michel Tremblay
ef2b0665b8 Merge branch 'ci-auto-deploy-release-tags'
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
2026-06-19 20:03:46 -04:00
38 changed files with 2711 additions and 26 deletions

69
CHANGELOG.md Normal file
View file

@ -0,0 +1,69 @@
# Changelog
All notable changes to this project are documented here. Versions are git tags;
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
as `0.0.0a1` are built and staged only).
## [0.1.0] - 2026-06-20
### Security
- Enforce the 12h session lifetime server-side (reject sessions older than the TTL
even if the sealed cookie is intact), so a leaked cookie can't be replayed forever.
- Send `X-Content-Type-Options: nosniff` when serving user-uploaded receipt and
attachment bytes.
### Docs
- Add deploy docs: `deploy/CICD.md` (which git push/tag triggers which pipeline
steps) and `deploy/AUTH.md` (the Authelia/OIDC integration contract + a sequence
diagram of the login flow). Cross-linked from the README and `deploy/INSTALL.md`.
## [0.0.4] - 2026-06-20
### Changed
- AI tab: correction notes are now full-width, wrapping, auto-growing textareas
(Save/Delete per note) instead of single-line inputs that truncated long notes,
so the whole note is readable and editable.
## [0.0.3] - 2026-06-20
### Added
- AI classifier correction notes + misread review (new "AI" tab).
- A global list of free-text correction notes is appended to the classifier
prompt; managed inline (add/edit/delete). The notes table is temporal (edits
soft-delete + insert), seeded once from a no-PII default list (documented in the
README), and read live per classification.
- A read-only view of the exact assembled prompt.
- Every AI-run upload records the suggestion blob + model (round-tripped from the
browser). Misreads — where a final field differs from the AI's guess — are
derived and listed for review; reviewing shows the image and per-field
guess-vs-entered, and lets the user attribute which note(s) fixed it (or close
it unresolved).
- See SPEC.md §10 and DESIGN.md item 15.
## [0.0.2] - 2026-06-20
### Added
- Optional tags on a receipt. An "Add tags" button on the upload form (after
"Who") opens an in-page card with an alphabetical chip mosaic; tap to select or
deselect, and create a new tag inline (auto-selected for this receipt). Tags are
a shared, free-form vocabulary stored separately from the fixed categories.
- New tags are written to the catalog only when the receipt is actually saved,
matched case-insensitively so casing variants don't duplicate.
- A receipt's tags are shown on the confirmation page and in the recent lists.
- See spec.md item 14. Out of scope for now: tag-based filtering, tally-by-tag,
and editing/merging tags in Manage.
## [0.0.1] - 2026-06-19
### Added
- Upright receipt images (EXIF orientation normalization). Phone cameras often
store a photo sideways with an EXIF "Orientation" tag that only some viewers
honor, so receipts could appear rotated. On upload the indicated rotation is now
baked into the pixels (and the tag dropped) so the stored image is upright in
every consumer — browser, download, the AI classifier, and any future export.
- Applies only when orientation is actually known (a JPEG with an EXIF tag of
2..8). Images with no tag, an already-upright tag, non-JPEG images, and PDFs
are left byte-for-byte untouched — no guessing from image content.
- Runs on the primary receipt, every additional attachment, and the image sent
to the AI classifier. New uploads only; existing stored receipts are unchanged.
- See spec.md item 13.

View file

@ -1,3 +1,11 @@
================================================================================
DESIGN JOURNAL — historical, chronological record of decisions and rationale.
For the current expected behavior of the app, see SPEC.md (the source of truth).
This file explains WHY things were built the way they were; SPEC.md says WHAT the
app does today. When behavior changes, update SPEC.md; append context here only if
the reasoning is worth preserving.
================================================================================
HSA Receipt Tracker — Requirements (v1) HSA Receipt Tracker — Requirements (v1)
Purpose Purpose
Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting. Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting.
@ -353,4 +361,193 @@ half-written row, and recover automatically on the next boot — no manual repai
is not auto-cleaned. The DB/blob is the source of truth for serving. is not auto-cleaned. The DB/blob is the source of truth for serving.
- Recovery from true corruption (beyond crash-consistency) is restore-from-backup: - Recovery from true corruption (beyond crash-consistency) is restore-from-backup:
the metadata-only daily backup (item 11) recovers the records; the full .db the metadata-only daily backup (item 11) recovers the records; the full .db
export (/export/db, blobs included) recovers records + images. export (/export/db, blobs included) recovers records + images.
13. Upright receipt images (EXIF orientation normalization)
Phone cameras often save a photo in the sensor's native orientation plus an EXIF
"Orientation" tag that says "rotate me when displaying". Some viewers honor the
tag and some don't, so a receipt that looks fine in one place shows up sideways in
another (and the bytes are stored verbatim, so the problem follows the file). On
upload, bake the indicated rotation into the pixels so the stored image is upright
for EVERY consumer — browser, download, the AI classifier, a future PDF export —
not just EXIF-aware ones.
- Act only when orientation is actually KNOWN. A JPEG carrying an EXIF
Orientation tag of 2..8 is decoded, rotated/flipped upright, re-encoded
(JPEG, q≈90), and the tag dropped. A tag of 1 (already upright) or NO tag at
all — the common case, where there is genuinely no way to know which way is up
— leaves the original bytes byte-for-byte untouched. We never infer orientation
from image content or guess; an unrotatable image is left as-is, never wrong.
- Scope: JPEG only (where camera orientation tags live in practice). PDFs and
other image types pass through unchanged, as does any file that fails to decode
(kept rather than lost).
- Applies to the primary receipt, every additional attachment (item 10), and the
image sent to the AI classifier (an upright image reads more reliably). It runs
after the MIME allowlist check and before the dual-write, so file_size_bytes and
both stored copies (disk + DB blob) reflect the normalized bytes.
- Client side cannot help: there is no browser/camera API to disable capture
rotation, so this is necessarily a server-side fix.
Out of scope: content-based auto-rotation (detecting "up" without metadata),
rotating PDFs, and backfilling already-stored receipts.
14. Optional tags on a receipt
Let the user attach zero or more free-form TAGS to a receipt at upload time, on top
of the fixed category and the optional "who". Tags are a shared, user-extensible
vocabulary (e.g. "orthodontics", "tax-2026", "Jude-braces") for grouping receipts
however the household likes, without touching the fixed category list. Optional:
a receipt with no tags is normal.
Upload UI:
- An "Add tags" button on the upload form, placed AFTER the "Who" control. A
summary next to it shows the current selection (e.g. "3 tags" or the chip
labels), so the user sees what's attached without opening the picker.
- Tapping it opens an IN-PAGE overlay (modal card), NOT a separate page. The
upload form already holds in-progress state — the chosen file, the AI-filled
amount/date/category, the "who" — and navigating away would lose it (the file
input especially). The picker must preserve all of that; closing it returns to
the same half-filled form.
- The card shows every existing tag as a chip in a wrap/mosaic layout, sorted
alphabetically (case-insensitive, like the other lookups). Tap a chip to select
it; tap again to deselect. Selected chips are visually distinct (filled vs
outline, checkmark, etc.). Multiple selections allowed.
- At the bottom, a "new tag" text field + add control. Creating a tag adds its
chip to the mosaic and marks it SELECTED for this receipt by default. The new
chip is client-side only until the receipt is submitted (see persistence).
Creating a name that already exists (case-insensitive, trimmed) just selects
the existing chip rather than making a duplicate.
- A "Done with tags" button closes the card and returns to the form with the
selection retained. The receipt is then submitted normally; tags ride along in
the same POST /upload submission (no separate endpoint), like attachments.
Data model — new `tags` lookup table and a many-to-many join:
- tags: id (INTEGER PK), label (TEXT, unique case-insensitively). Same shape and
ordering convention as categories/people (ORDER BY label COLLATE NOCASE).
- receipt_tags: (receipt_id FK → receipts.id, tag_id FK → tags.id), composite
primary key (receipt_id, tag_id) so a tag can't be linked twice to one receipt.
Index on tag_id for "receipts with this tag" lookups later.
- Tags are global/shared (both users see the same catalog), consistent with the
shared-visibility model. No per-user tag namespaces.
Persistence and submission:
- The form submits the selected tags as a list (existing tag ids and/or new tag
LABELS). On insert, the server resolves each: known label/id → reuse; unknown
label → create the tag row first (create-if-missing by normalized label), then
link. This means a NEW tag is written to the catalog only when its receipt is
actually saved — an abandoned upload never litters the tag list.
- Tag links are written after the receipt row exists (it owns the id), in the same
request that saves the receipt and its attachments.
- Normalization: trim surrounding whitespace; match/dedup case-insensitively;
store the label as the user first typed it (display casing preserved).
- Soft-deleting a receipt hides its tag links along with it (the catalog entries
persist). Tags never affect duplicate detection or Tally totals.
Display:
- A receipt's tags are shown as chips wherever its details appear (the recent
lists, item 4/5, and the confirm page after upload). AI auto-fill does NOT
suggest tags; tagging is a manual, deliberate act.
Out of scope (for now):
- Tag-based filtering/search of receipts and a tally-by-tag view (likely the next
step once tags exist).
- Editing/renaming/merging/deleting tags in the Manage page. Free-form tags will
accumulate cruft and a cleanup surface (akin to item 6 for people) will be
wanted eventually, but not in this item.
- Adding/removing tags on an already-saved receipt (no edit/detail page yet, same
limitation as attachments in item 10).
15. AI classifier notes + failure review (implemented — see SPEC.md §10)
A closed loop for improving receipt classification over time: the user accumulates
corrective instructions ("notes") that are appended to the classifier prompt, and
reviews past misreads one by one to author and attribute those fixes. Big change;
spans storage, the upload path, the classifier prompt, and a new top-level tab.
Motivation: classification will misread some receipts (a vendor's odd date format,
a statement that lists the patient under "Guarantor", etc.). Rather than hardcode
ever-more rules, let the two users add their own corrections as they hit failures,
and give them a place to study failures and decide what fixed them.
A. Notes — what they are
- A single, GLOBAL, free-text list of correction lines, appended to the system
prompt as a "Corrections/Notes" appendix. Global because at classify time the
app does not yet know the category/vendor, so scoped notes couldn't be selected.
- Authored English, separate from the prompt's DERIVED parts (people + name
variants, category names/examples, today's date), which stay computed in code.
The static prompt scaffolding (the rules/warnings) stays in code too — notes
only AUGMENT it; this item does not externalize the whole prompt.
- Curated, not append-forever: each active note costs tokens on every scan and too
many dilute the instructions, so delete/edit matter as much as add. Realistically
a handful.
B. Notes — storage (temporal)
- Table ai_notes(id, text, created_at, deleted_at). Active set = deleted_at IS
NULL, ordered by created_at; that set is what gets appended to the prompt.
- EDIT = soft-delete the old row + insert a new one (never update in place), so the
full history is preserved. The notes active at any time T are
created_at <= T AND (deleted_at IS NULL OR deleted_at > T) — which is what lets a
failure be matched against the notes that existed when it happened (section E).
- Read per /classify call (live; no restart needed). Rides along in /export/db and
the daily backup like everything else.
- SEEDED ONCE from a hardcoded default list in code (the same defaults are
published in the README for humans), inserted only when the table is completely
empty — so a deliberately-deleted default does not resurrect on restart. Never
read from a file/config at runtime; no export/mirror file. Defaults carry no PII;
user-authored notes live only in the DB (private, not in git).
C. Capturing classifications (the plumbing)
- The /classify result is produced server-side but only reaches the browser (to
pre-fill the form); by POST /upload the server no longer holds it. So the browser
PERSISTS the suggestion blob + model and sends it back in a hidden field on
submit. (The API key is and remains server-side — only the suggestions round-trip.)
- Store one row per AI-run upload:
classifications(receipt_id PK/FK, model, response_json, created_at,
reviewed BOOL, reviewed_at, resolution).
Skip-AI or classification-disabled uploads create NO row.
- response_json is the /classify blob (suggested amount/date/category_id/person_id,
the raw_* text the model read, cost, model). Treat it as DIAGNOSTIC data, not
ground truth — it is client-supplied and could be tampered with; nothing
security-relevant depends on it. Mild PII (raw read name) but DB-only.
D. Failures are DERIVED, not stored
- A "failure"/miss = at least one of the 4 AI-suggested fields (amount, date,
category, who) differs from the receipt's final stored value, where AI returning
null/empty and the user filling it COUNTS as an override (the AI missed it).
- Receipts are immutable (no edit) and the blob is immutable, so the comparison
inputs never change — derived overrides can never drift, so we store no
redundant per-field booleans. Per-field detail (which of the 4, AI-guess vs.
corrected) is computed on demand from the blob vs. the receipt when a failure is
displayed. Volume is tiny, so deriving each time is cheap. (If SQL-level
filtering/stats ever matter, a single computed had_override flag could be added
purely as an index — not the four booleans.)
- Review state (reviewed, reviewed_at, resolution) and the fix links below are the
only non-derivable things, so they ARE stored.
E. Review — the loop
- New top-level "AI" tab with three sections (one tab unless it grows):
1. Notes — list active notes; add / edit / delete (each edit = soft-delete +
insert per section B).
2. Prompt — READ-ONLY view of the live assembled system prompt (static
scaffolding + injected people/categories/today + current active notes), so
the user sees exactly what is sent. View-only for now; not editable.
3. Review — unreviewed failures, one at a time.
- Reviewing one failure shows: the receipt IMAGE, the MODEL used, which fields were
overridden (AI guess vs. corrected, derived from the blob), and the NOTES ADDED
SINCE this failure's created_at (from the ai_notes temporal history). The user
marks which of those notes were the fix → recorded in miss_fixes(receipt_id,
note_id); if none fixed it, close as resolution='unresolved'. Either way set
reviewed=1, reviewed_at. ("Switched to a stronger model" as a resolution reason
may be added later, since model is recorded.)
F. Out of scope / future
- Per-category or vendor-scoped notes (global only, by design above).
- Automatic re-classification/recompute of past failures. The data supports it
(model + active-notes-at-time-T + the stored image), but review stays MANUAL —
the user eyeballs the image to understand the miss.
- Editing the static prompt scaffolding (view-only here) and externalizing the
whole prompt.
- Accuracy-rate stats across all classifications.
- No prompting the user to write a note at upload time — failures are recorded
silently and dealt with later in the Review tab (likely on a real computer).

43
README.md Normal file
View file

@ -0,0 +1,43 @@
# HSA Receipt Tracker
A small, mobile-first web app for two household users to capture and archive
HSA-eligible receipts (photo or PDF) for future reimbursement and tax
substantiation, with optional AI auto-fill of the amount/date/category/patient.
- **What it does (current behavior):** [SPEC.md](SPEC.md) — the source of truth.
- **Why it's built this way (history & rationale):** [DESIGN.md](DESIGN.md).
- **Version log:** [CHANGELOG.md](CHANGELOG.md).
## Running
It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via
environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and
`./scripts/run.sh` runs it locally.
Deployment:
- **[deploy/CICD.md](deploy/CICD.md)** — what each git push/tag triggers (branch =
build+test; release tag `X.Y.Z` = build+deploy; pre-release tag = build+stage).
- **[deploy/INSTALL.md](deploy/INSTALL.md)** — one-time host setup, on-disk layout,
manual deploy, and rollback.
- **[deploy/AUTH.md](deploy/AUTH.md)** — the Authelia (OIDC) integration: which
config fields must agree with which app env vars, and how access is granted/revoked.
## AI classifier correction notes
When an API key is configured, each upload is read by the model to pre-fill the
form. You can steer it with **correction notes** — free-text rules appended to the
classifier prompt — managed under the **AI** tab. When the model misreads a
receipt, that upload is recorded; the AI tab lets you review misreads one by one
and attribute which note fixed each.
Notes live only in the database (private, never committed, included in `/export/db`
backups). On first run the table is seeded once with these **default notes** (no
PII), which you can edit or delete:
1. Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.
2. When both a service/visit date and a separate statement, print, or due date appear, use the service date.
3. "Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.
These defaults are defined in code (`internal/storage/ai_notes.go`); this list is
the human-readable copy. They are only seeded when the notes table is empty, so a
deleted default does not come back on restart.

357
SPEC.md Normal file
View file

@ -0,0 +1,357 @@
# HSA Receipt Tracker — Specification
This is the source of truth for the app's **current** expected behavior, organized
by domain. It describes what the app does today, not how it got here — for the
history and rationale of each decision see [DESIGN.md](DESIGN.md); for the
version-by-version log see [CHANGELOG.md](CHANGELOG.md).
Keywords **MUST**, **SHOULD**, and **MUST NOT** mark hard requirements vs.
recommendations. When behavior changes, amend the relevant section here in the same
commit.
---
## 1. Purpose & scope
Capture and archive HSA-eligible receipts for two household users, for future
reimbursement and tax substantiation. The app is mobile-first (phone camera
capture matters). It deliberately does **not** do reimbursement tracking, in-place
editing, or general reporting beyond the Tally view.
---
## 2. Users, authentication & authorization
- Two users, both with full shared access — there is no per-user data isolation;
everything is visible to every authorized user.
- Authentication is **OIDC with PKCE** against an Authelia issuer (`ISSUER_URL`),
using the registered client (`OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`).
- The login flow:
- `GET /login` generates a PKCE verifier, state, and nonce, stores them in a
short-lived (10 min) encrypted `hsa_login` cookie, and redirects to Authelia.
- `GET /callback` validates state (CSRF), exchanges the code with the PKCE
verifier, verifies the ID token and nonce, then reads claims. Groups and email
come from the ID token, falling back to the UserInfo endpoint (Authelia serves
these from UserInfo by default).
- The user identity (**subject**) is the email, or the preferred username if no
email is present.
- **Authorization gate:** the user MUST be a member of `REQUIRED_GROUP` (exact,
case-sensitive match). Otherwise the callback returns **403**.
- On success a `hsa_session` cookie is set for **12 hours**. The session is an
**AES-256-GCM** sealed token (tamper-evident; key = `SHA-256(SESSION_SECRET)`),
and the 12h lifetime is enforced **server-side** (a session older than the TTL is
rejected even if the cookie value is intact), not just via the cookie's MaxAge.
Cookies are `HttpOnly`, `SameSite=Lax`, and `Secure` whenever `REDIRECT_URL` is
`https://`.
- `GET /logout` clears the session and redirects to `/login`.
- **Public routes** (no session required): `/healthz`, `/static/*`, `/login`,
`/callback`, `/logout`. **Every other route requires a valid session**; missing
or invalid sessions redirect to `/login`.
---
## 3. Configuration
All runtime config is read from environment variables (a local `.env` is loaded
best-effort on startup; see [.env.example](.env.example)). Required vars (startup
fails if any is missing): `ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`REDIRECT_URL`, `REQUIRED_GROUP`, `SESSION_SECRET`.
| Var | Default | Meaning |
|---|---|---|
| `LISTEN_ADDR` | `:8080` | HTTP listen address |
| `DB_PATH` | `./data/hsa.db` | SQLite database file |
| `STORAGE_DIR` | `./data` | storage **root** (receipts/attachments live under it) |
| `MAX_UPLOAD_MB` | `32` | per-file upload cap (reject larger) |
| `CONFIG_PATH` | `./config.json` | people + categories catalog |
| `CLAUDE_API_KEY` | _(empty)_ | Anthropic key; empty **disables** AI classification |
| `CLASSIFY_MODEL` | `claude-haiku-4-5-20251001` | classification model id |
| `BACKUP_DIR` | `./data/dbbackup` | scheduled metadata-only backups |
| `BACKUP_INTERVAL` | `168h` | backup frequency (Go duration); `0` disables |
| `BACKUP_KEEP` | `8` | most-recent backups to retain |
**Catalog (`config.json`):** defines `persons` (last/first/middle) and
`categories` (name + authored `examples`). On startup the app seeds the canonical
person labels (`First Last`) and category names into the DB (insert-if-absent), so
labels renamed via Manage survive restarts. The per-category `examples` are used
only to build the classifier prompt and are intentionally **not** stored in the DB.
A missing/invalid catalog is a fatal startup error.
---
## 4. Data model (SQLite)
- **categories**`id`, `label` (unique). Seeded; renamable via Manage.
- **people**`id`, `label` (unique). Seeded from catalog; renamable via Manage. Format is "<FIRST NAME> <LAST NAME>"
- **receipts**`id` (UUID), `uploaded_by`, `uploaded_at`, `receipt_date`,
`amount_cents` (integer — money is never a float), `category_id` (FK, required),
`person_id` (FK, nullable), `file_path`, `image_data` (BLOB), `file_size_bytes`,
`original_filename`, `mime_type`, `deleted_at` (nullable, soft delete).
- **attachments** — same shape as receipts minus the receipt-specific fields, plus
`receipt_id` (FK). Supplementary files for a receipt; no amount/date/category of
their own.
- **tags**`id`, `label` (unique, **case-insensitive** via `COLLATE NOCASE`).
- **receipt_tags** — (`receipt_id`, `tag_id`) many-to-many, composite primary key.
- **ai_notes**`id`, `text`, `created_at`, `deleted_at` (nullable). Temporal:
edits soft-delete + insert, so the active set at any past time is recoverable.
- **classifications**`receipt_id` (PK/FK), `model`, `response_json` (the AI
suggestion blob), `created_at`, `reviewed`, `reviewed_at`, `resolution`.
- **miss_fixes** — (`receipt_id`, `note_id`) linking a reviewed misread to the
note(s) credited with fixing it.
`PRAGMA foreign_keys=ON`, `journal_mode=WAL`, `busy_timeout=5000` are set on open.
---
## 5. Upload & receipt creation
The upload form (`GET /{$}`) and its handler (`POST /upload`) are the core flow.
### 5.1 Fields & validation
A submission MUST include: an **amount**, a **date** (`YYYY-MM-DD`), a **category**,
and a **receipt file**. The **who** (person) and **tags** are optional, as are
additional attachment files.
- **Amount** is parsed to integer cents; accepts an optional `$`, spaces, and
thousands separators; rejects empty, non-numeric, more than two decimals, zero,
and negatives.
- **Category** MUST reference an existing category; **who**, if provided, MUST
reference an existing person.
- **File** MUST be detected as `image/*` or `application/pdf` and be within
`MAX_UPLOAD_MB`. Same allowlist and cap apply to each attachment.
- On any validation error the form re-renders with messages and **nothing is
written**; the user's entered values, including the tag selection, are preserved.
### 5.2 Image orientation normalization
On upload, a JPEG carrying an EXIF Orientation tag of 2..8 is rotated/flipped
upright, re-encoded (JPEG q≈90), and the tag dropped, so the stored image is
upright everywhere. Images with no tag, an upright tag (1), non-JPEG images, PDFs,
or anything that fails to decode pass through **byte-for-byte**. This applies to
the receipt, every attachment, and the image sent to the classifier. (No
content-based guessing; new uploads only.)
### 5.3 AI auto-fill (classification)
When `CLAUDE_API_KEY` is set, attaching a file triggers `POST /classify`, which
sends the (orientation-normalized) image to the configured model and returns
suggested amount, date, category, and who to **pre-fill** the form. It mutates no
state — the user reviews and submits normally.
- Classification is **forced tool-use**, so the result is always structured. The
**person** is constrained to the canonical labels (kept only on exact match);
the **category** is constrained to the configured set, falling back to the last
(most general) category if the model returns something off-list. Date/amount are
dropped if null-ish.
- The user can tick **Skip AI** to suppress the call and fill fields by hand. The
footnote always shows which model runs (or that auto-fill is off).
- After a successful classification the per-call cost is shown in cents (¢),
computed locally from the response's token usage and a hardcoded per-model rate
table. An unknown model id shows token usage but omits the ¢ figure (never a
guess).
- The active **correction notes** (§10) are appended to the classifier prompt. The
suggestion the model returns is round-tripped through the form and **recorded**
against the saved receipt, so misreads can be reviewed later (§10). Skip-AI and
disabled-classification uploads record nothing.
### 5.4 Duplicate warning
Whenever date and amount are both known (edit: those are mandatory fields aren't they?), the form calls `GET /duplicates` and warns
if a non-deleted receipt already has the **same `receipt_date` and `amount_cents`**.
The warning lists each match (amount, date, category, who, uploader, upload time,
filename) and **disables submit** until the user ticks "Add it anyway". No match →
submit proceeds normally.
### 5.5 Attachments
The form accepts optional extra files in the same submission (a second page, an
EOB, an itemized list). They inherit the parent receipt's identity, carry no
metadata of their own, and are saved after the receipt row exists. AI runs only on
the primary receipt, never on attachments.
### 5.6 Tags
The form has an **"Add tags"** control after **Who** that opens an in-page modal
with an alphabetical chip mosaic. The user taps chips to select/deselect and can
create a new tag inline (auto-selected for this receipt). Selected tags submit with
the form.
- The tag catalog is **shared/global** and **free-form**.
- A new tag is written to the catalog **only when the receipt is actually saved**
(create-if-missing by label), so abandoned uploads add nothing. Labels are
trimmed and matched/deduped **case-insensitively** (first-seen casing kept).
- A receipt's tags are shown on the confirmation page and in the recent lists.
- The classifier does **not** suggest tags — tagging is a manual act.
### 5.7 Storage (dual-write & file layout)
Each receipt and attachment is written in **both** places:
1. **Filesystem**, under the storage root with a browsable, dated, amount-tagged
name:
- receipts: `<STORAGE_DIR>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>`
- attachments: `<STORAGE_DIR>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>`
- Year/month/day and amount come from the **receipt** date/amount (attachments
use their parent's), so an expense's files sort together. Path components
derive only from date + amount (no user filename → no path-traversal surface).
- Name collisions get a numeric suffix on the stem (`_1`, `_2`, …) via exclusive
create (`O_CREATE|O_EXCL`), so concurrent uploads never overwrite.
2. **DB blob** (`image_data`), so the single `.db` file is a complete, portable
dataset (metadata + all images).
`original_filename` and `mime_type` are kept as metadata. The file is written
before the DB row, so a crash in between can leave a harmless orphan file (never a
row missing its data); orphans are not auto-cleaned. The DB blob is the source of
truth for serving.
After a successful save, a confirmation page shows category, who, amount, date,
filename, attachment count, and tags, with links to add another / Manage / Export.
---
## 6. Viewing & serving files
- `GET /receipt/{id}/file` and `GET /attachment/{id}/file` serve the stored bytes
**from the DB blob** (so serving works even if the on-disk copy is gone), with
the original MIME type, `Content-Disposition: inline`, and
`X-Content-Type-Options: nosniff` (so the browser won't sniff user bytes past the
declared, upload-time-allowlisted type).
---
## 7. Recent lists
Two read-only listings of non-deleted receipts, 10 per page with a "Load next 10"
control (offset paging):
- `GET /recent` — ordered by **upload date** (`uploaded_at` desc).
- `GET /recent/receipts` — ordered by **receipt date** (`receipt_date` desc).
Each row shows: amount (linking to the file), receipt date, category, the **who**
abbreviated to initials + last name (e.g. "Jean-Michel Tremblay" → "JM. Tremblay";
each hyphenated first-name part is initialed), the upload date, paperclip links for
any attachments, and the receipt's tag chips.
---
## 8. Tally
`GET /tally` — a read-only person × year matrix of summed amounts (dollars):
- One row per person, one column per year that has data; receipts with no who are
grouped under **"Unassigned"** (sorted last).
- Right-margin column: total per person across years. Bottom-margin row: grand
total per year. Bottom-right: overall grand total.
- Excludes soft-deleted rows. Years are bucketed by the **receipt** date.
---
## 9. Manage lists
`GET /manage` lets authorized users curate the lookup lists:
- **Categories** and **People**: rename existing entries (`POST
/manage/categories/rename`, `/manage/people/rename`) and add new ones (`POST
/manage/categories`, `/manage/people`). Renames propagate everywhere because
receipts reference these by id.
- On startup, stray partial-name people (e.g. a leftover "Jude") are merged into the
unambiguous canonical person ("Jude Tremblay"), reassigning their receipts first,
so no receipt loses its who. Seeding is idempotent.
- Tags are **not** managed here (see §15 gaps).
---
## 10. AI classifier notes & misread review
`GET /ai` — a tab for improving classification over time.
- **Correction notes:** a single global list of free-text rules, appended to the
classifier prompt (§5.3). Managed inline — add (`POST /ai/notes`), edit (`POST
/ai/notes/edit`), delete (`POST /ai/notes/delete`). The table is **temporal**: an
edit soft-deletes the old row and inserts a new one, so the notes active when any
past receipt was classified are recoverable. Seeded once from a hardcoded default
list (documented in the README; no PII) only when the table is empty, so a deleted
default does not return. Read live on every classify call (no restart).
- **Read-only prompt view:** renders the exact system prompt that would be sent today
(static scaffolding + injected people/categories/today + active notes), when
classification is enabled.
- **Misread review:** every AI-run upload stores the suggestion blob + model. A
**miss** is *derived* (never stored) — any of the four AI fields (amount, date,
category, who) differing from the receipt's final value, where AI-null-then-filled
counts as a miss. The review queue lists unreviewed misses; `GET /ai/review/{id}`
shows the receipt image, the AI-guess-vs-entered per field, and the notes added
*since* that classification. The user ticks which notes fixed it (`POST
/ai/review/{id}`) → resolution `fixed` and `miss_fixes` links; ticking none closes
it `unresolved`. Either way the row is marked reviewed.
Notes live only in the DB (private, not in git; included in `/export/db` and
backups). The stored suggestion blob is client-supplied diagnostic data, not ground
truth.
---
## 11. Export
- `GET /export/db` — downloads a consistent point-in-time copy of the SQLite
database produced via `VACUUM INTO` (the live file is never locked/served
directly). Because images are stored as blobs, this single file is the **complete
dataset**. `Content-Type: application/octet-stream`, filename
`hsa-export-YYYY-MM-DD.db`.
- `GET /export/db?blobs=false` — a **metadata-only** copy with image blobs stripped
(≈99% smaller); filename `hsa-export-YYYY-MM-DD-metadata.db`.
- Read-only; mutates no app state. Amounts stay integer cents in the export.
---
## 12. Scheduled backups
A background goroutine writes **metadata-only** snapshots (blobs stripped from both
receipts and attachments) into `BACKUP_DIR`:
- Frequency `BACKUP_INTERVAL` (default weekly; `0` disables), retaining the most
recent `BACKUP_KEEP` (default 8).
- Files are named `hsa_sqlite_backup_<YYYY_MM_DD>.db`**one per day**; a same-day
re-run is a no-op, so frequent restarts never spam the directory.
- Runs once at startup (if the latest backup is due), then on the interval. Failures
are logged, never fatal. Images are recoverable from the on-disk files and the
full `/export/db`; the daily backup recovers metadata.
---
## 13. Durability & crash recovery
- WAL mode makes the DB self-healing: an interrupted write is rolled forward if
committed, discarded otherwise, bringing the DB up at its last committed state.
- On open the app runs `PRAGMA quick_check`; a crash-interrupted write passes, but
genuine corruption fails fast and the app **refuses to start**, pointing the
operator at `BACKUP_DIR` / a `.db` export to restore.
---
## 14. Deployment & operations
- The app builds as a fully static binary (`CGO_ENABLED=0`; the SQLite driver is
pure Go), runnable on any `linux/<arch>`.
- It runs as a **user systemd service** behind a reverse proxy. Tagged releases are
built and deployed by Forgejo Actions: a release tag `X.Y.Z` is built, tested,
staged into `~/hsa-app/releases/hsa-app-V<tag>/`, then **activated** (symlink swap
+ service restart); pre-release tags (e.g. `0.0.0a2`) are built and staged only.
See [deploy/INSTALL.md](deploy/INSTALL.md).
---
## 15. Known gaps / not yet implemented
These are intentionally absent today (the schema or storage layer may support some;
the UI/endpoint does not):
- **Deleting receipts:** `deleted_at` and a `SoftDelete` storage method exist and
all listings/tally already exclude deleted rows, but **no route or UI triggers a
delete** — there is currently no way to delete a receipt from the app.
- **Editing** existing receipts (fix by deleting and re-adding — once delete exists).
- **Adding attachments or tags to an already-saved receipt** (no edit/detail page).
- **Duplicate same-person highlighting:** the warning matches on date + amount only;
it does not yet weight or highlight a same-person match differently.
- **Tag features:** no tag-based filtering/search, no tally-by-tag, and no
editing/merging/deleting tags in Manage (free-form tags will accumulate; a cleanup
surface is future work).
- **Archive/zip export** (`/export/archive`): not implemented; only `/export/db`.
- **HEIC** images are not decodable in pure Go; browser camera uploads arrive as
JPEG in practice.
Related future work now that AI notes + review exist (§10): per-category/vendor-
scoped notes, automatic re-classification of past misses, editing the static prompt
scaffolding, and accuracy-rate stats. See [DESIGN.md](DESIGN.md) item 15.

110
deploy/AUTH.md Normal file
View file

@ -0,0 +1,110 @@
# Authentication — Authelia (OIDC)
The app is an **OIDC Relying Party**: it runs the standard authorization-code + PKCE
flow against Authelia directly. It is **not** behind Authelia's forward-auth /
`access_control` (note `hsa.maisym.com` is intentionally absent from those rules).
So Authelia **authenticates** the user (password + WebAuthn); the **app**
**authorizes** them via the `hsa-users` group claim.
## Flow — every redirect & call, and what it carries
Two cookies are in play: **`hsa_login`** (short-lived, holds the PKCE verifier +
state + nonce during the login round-trip) and **`hsa_session`** (the 12h
authenticated session). Front-channel = via the browser (302 redirects);
back-channel = direct server-to-server calls the browser never sees.
```mermaid
sequenceDiagram
autonumber
actor Browser
participant App as App (hsa.maisym.com)
participant Authelia as Authelia (auth.maisym.com)
Note over App,Authelia: at startup, the App discovers Authelia via<br/>GET /.well-known/openid-configuration and JWKS<br/>(learns its endpoints and signing keys)
Browser->>App: GET / (protected page, no hsa_session)
App-->>Browser: 302 to /login
Browser->>App: GET /login
Note right of App: make PKCE verifier, state, nonce<br/>seal them into the hsa_login cookie (10m)
App-->>Browser: 302 to Authelia /authorize, Set-Cookie hsa_login<br/>params client_id, redirect_uri, response_type=code,<br/>scope openid profile email groups, state, nonce,<br/>code_challenge S256 of verifier, method S256
Browser->>Authelia: GET /authorize with those params
Note over Browser,Authelia: password and WebAuthn (2FA)
Authelia-->>Browser: 302 to /callback with code and state
Browser->>App: GET /callback (code, state, Cookie hsa_login)
Note right of App: decode hsa_login and check<br/>returned state equals stored state (CSRF)
App->>Authelia: POST /token back-channel<br/>code, code_verifier, redirect_uri,<br/>grant_type authorization_code,<br/>Authorization Basic client_id and client_secret
Authelia-->>App: access_token and id_token (JWT)
Note right of App: verify id_token signature via JWKS<br/>and check nonce equals stored nonce
App->>Authelia: GET /userinfo back-channel<br/>Authorization Bearer access_token
Authelia-->>App: email, preferred_username, groups
Note right of App: require hsa-users in groups, else 403<br/>seal Session subject, groups, issuedAt<br/>into hsa_session cookie (12h, AES-256-GCM)
App-->>Browser: 302 to /, Set-Cookie hsa_session, clear hsa_login
Browser->>App: GET / (Cookie hsa_session)
Note right of App: decrypt and verify hsa_session,<br/>reject if older than the 12h TTL
App-->>Browser: 200, the app
```
## The contract — what must agree on both sides
| Authelia `configuration.yml` | App `.env` | Notes |
|---|---|---|
| issuer `https://auth.maisym.com` | `ISSUER_URL` | app discovers `ISSUER_URL/.well-known/openid-configuration` |
| `client_id: hsa-tracker` | `OIDC_CLIENT_ID` | exact match |
| `client_secret` (**argon2id hash**) | `OIDC_CLIENT_SECRET` (**plaintext**) | Authelia stores the hash; the app holds the plaintext that hashes to it |
| `redirect_uris` | `REDIRECT_URL` | must match character-for-character (`https://hsa.maisym.com/callback`) |
| `scopes: [openid, profile, email, groups]` | — | the **`groups`** scope delivers the claim authz depends on |
| `require_pkce: true`, `pkce_challenge_method: S256` | — | the app always uses PKCE S256 |
| `token_endpoint_auth_method: client_secret_basic` | — | matches the Go OAuth2 client default |
| `userinfo_signed_response_alg: 'none'` | — | the app reads UserInfo as plain JSON |
| — | `REQUIRED_GROUP=hsa-users` | the group the app demands; defined per-user in `users_database.yml` |
## The Authelia client block (this deployment)
In `identity_providers.oidc.clients` (secret redacted):
```yaml
- client_id: 'hsa-tracker'
client_name: 'HSA Receipt Tracker'
client_secret: '$argon2id$v=19$m=65536,t=3,p=4$argon2xxxxxxxxxxx' # hash; plaintext lives in the app .env
public: false
authorization_policy: 'two_factor' # users must pass WebAuthn
require_pkce: true
pkce_challenge_method: 'S256'
redirect_uris:
- 'https://hsa.maisym.com/callback'
- 'http://localhost:8080/callback' # local dev
scopes: ['openid', 'profile', 'email', 'groups']
response_types: ['code']
grant_types: ['authorization_code']
token_endpoint_auth_method: 'client_secret_basic'
userinfo_signed_response_alg: 'none'
```
## The three things that actually bite
1. **client_secret is a hash on the Authelia side, plaintext on the app side.** To
rotate: `authelia crypto hash generate argon2 --password '<plaintext>'`, put the
resulting `$argon2id$...` **hash** in `configuration.yml` and the **plaintext** in
the app's `OIDC_CLIENT_SECRET`. A mismatch fails the token exchange.
2. **redirect_uri must match exactly** (scheme, host, path, no trailing slash) or
Authelia refuses the callback.
3. **The `groups` scope is load-bearing.** Drop it and the app gets no `groups`
claim → no `hsa-users`**403 for everyone**.
## Who can log in / how to revoke
Access requires an Authelia account that passes 2FA **and** is in `hsa-users`
(`users_database.yml`):
```yaml
users:
jm: { groups: ['admins', 'hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
lynna: { groups: ['hsa-users'], ... } # password: '$argon2id$...argon2xxxxxxxxxxx'
```
To revoke someone: remove them from the `hsa-users` group (or set `disabled: true`).
An already-issued app session still lasts up to its 12h server-side TTL.

77
deploy/CICD.md Normal file
View file

@ -0,0 +1,77 @@
# CI/CD — what git actions trigger what
The pipeline is a single Forgejo Actions workflow,
[.forgejo/workflows/build.yml](../.forgejo/workflows/build.yml), triggered on every
push (`on: [push]`, which covers both branch and tag pushes). This document explains
which git action produces which outcome. For one-time host setup, the on-disk
layout, and manual deploy/rollback, see [INSTALL.md](INSTALL.md).
## Trigger behavior at a glance
| You push… | Build + test | Stage binary to host | Activate (go live) |
|---|:---:|:---:|:---:|
| any branch (no tag) | ✅ | — | — |
| a **release** tag `X.Y.Z` (e.g. `1.4.0`) | ✅ | ✅ | ✅ |
| a **pre-release** tag (anything else, e.g. `0.0.0a3`) | ✅ | ✅ | — |
- **Branch push** → builds and runs the full test suite, uploads the `hsa` binary as
a run artifact. Nothing touches the server. This is your PR / pre-merge gate.
- **Release tag** `X.Y.Z` → builds, tests, copies the binary to the host, and makes
it live (symlink swap + service restart).
- **Pre-release tag** → builds, tests, and copies the binary to the host, but does
**not** activate it. Use this to park a build on the server (QA, manual
activation) without flipping production.
The release-vs-pre-release decision is purely the tag name: the **Activate** step
runs only when the tag matches the regex `^[0-9]+\.[0-9]+\.[0-9]+$`. Note this means
a `v`-prefixed tag (`v1.4.0`) would build + stage but **not** activate — tag with
**bare numbers** (`1.4.0`).
## What each step does
1. **Checkout**`git clone` of the pushed branch/tag (the runner doesn't use a
checkout action).
2. **Test**`go test ./...` inside a `golang:1.26` container.
3. **Build** — static binary: `CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w"`
(pure-Go SQLite, so no C toolchain; `-buildvcs=false` avoids the container's
dubious-ownership git error).
4. **Upload binary** — the `hsa` binary as a downloadable run artifact.
5. **Stage release** *(tags only)*`scp` the binary to
`~/hsa-app/releases/hsa-app-V<tag>/hsa` on the host and `chmod +x` it.
6. **Activate release** *(release tags `X.Y.Z` only)*`ln -sfn` the
`~/hsa-app/hsa` symlink to the new release and `systemctl --user restart hsa_app`.
Pre-release tags log "staged but not activated" and stop here.
## Cutting a release
```bash
# 1. land your change on main (push branch -> CI builds/tests -> merge)
# 2. tag and push:
git tag -a 1.4.0 -m "1.4.0 — <summary>"
git push origin 1.4.0
```
The tag push runs the whole pipeline through Activate. Watch it under the repo's
**Actions** tab; the **Activate release** step is the one that goes live. Roll back
by activating an older still-staged release (see [INSTALL.md](INSTALL.md)).
## Runner requirements
- One Forgejo `act_runner` registered with the **`shell`** label (`runs-on: shell`).
The job runs directly on the host, which therefore must have the **docker CLI**
available (the Test/Build steps run inside `golang:1.26` via `docker run`).
- The deploy steps `ssh`/`scp` from the runner host to the app host (they may be the
same machine). The app must run as a **user systemd service** named `hsa_app`,
reachable via `systemctl --user` — which requires `loginctl enable-linger` for the
deploy user (see [INSTALL.md](INSTALL.md)).
## Required repo configuration (Settings → Actions)
| Name | Kind | Purpose |
|---|---|---|
| `FORGEJO_SSH` | **Secret** | private SSH key authorized on the app host |
| `HSA_APP_HOST` | **Variable** | app host name / IP |
| `HSA_APP_USER` | **Variable** | SSH user that owns `~/hsa-app` and the user service |
If these are missing, branch pushes still build/test, but the Stage/Activate steps
fail on tags.

View file

@ -13,6 +13,9 @@ The symlink decouples "what's on disk" from "what's running," so activating a
staged pre-release or rolling back is just a symlink repoint + restart (see staged pre-release or rolling back is just a symlink repoint + restart (see
below). below).
For the pipeline side — which git pushes/tags trigger which steps, the runner
requirements, and the required repo secrets/variables — see [CICD.md](CICD.md).
## Layout on the host ## Layout on the host
Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives

4
go.mod
View file

@ -4,7 +4,9 @@ go 1.26.4
require ( require (
github.com/coreos/go-oidc/v3 v3.18.0 github.com/coreos/go-oidc/v3 v3.18.0
github.com/disintegration/imaging v1.6.2
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.52.0
) )
require ( require (
@ -14,9 +16,9 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.52.0 // indirect
) )

35
go.sum
View file

@ -1,27 +1,62 @@
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -66,6 +66,12 @@ func New(apiKey, model string, persons []config.Person, categories []config.Cate
} }
} }
// PromptPreview renders the exact system prompt that would be sent today with the
// given notes, for the read-only view on the AI tab.
func (c *Classifier) PromptPreview(today time.Time, notes []string) string {
return BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
}
const toolName = "record_receipt" const toolName = "record_receipt"
// --- request / response shapes --- // --- request / response shapes ---
@ -139,8 +145,8 @@ type toolInput struct {
// Classify sends the receipt to the model and returns its normalized reading. // Classify sends the receipt to the model and returns its normalized reading.
// today is used so the date heuristics ("closest to today, never future") are // today is used so the date heuristics ("closest to today, never future") are
// testable; pass time.Now(). // testable; pass time.Now().
func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) { func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string, notes []string) (Suggestion, error) {
system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02")) system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02"), notes)
imgBlock := apiBlock{ imgBlock := apiBlock{
Type: "image", Type: "image",

View file

@ -67,7 +67,7 @@ func TestClassifyHappyPath(t *testing.T) {
"raw_amount": "$42.50", "raw_amount": "$42.50",
}) })
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil { if err != nil {
t.Fatalf("Classify: %v", err) t.Fatalf("Classify: %v", err)
} }
@ -99,7 +99,7 @@ func TestClassifyNullsAndFallback(t *testing.T) {
"raw_amount": "", "raw_amount": "",
}) })
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil { if err != nil {
t.Fatalf("Classify: %v", err) t.Fatalf("Classify: %v", err)
} }
@ -122,7 +122,7 @@ func TestClassifyRejectsNonCanonicalPerson(t *testing.T) {
"person": "Dr. Emily Smith", // a provider, not a configured patient "person": "Dr. Emily Smith", // a provider, not a configured patient
"category": "Medical", "category": "Medical",
}) })
got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg", nil)
if err != nil { if err != nil {
t.Fatalf("Classify: %v", err) t.Fatalf("Classify: %v", err)
} }
@ -148,7 +148,7 @@ func TestClassifyIntegration(t *testing.T) {
if err != nil { if err != nil {
t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err) t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err)
} }
got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg") got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg", nil)
if err != nil { if err != nil {
t.Fatalf("Classify: %v", err) t.Fatalf("Classify: %v", err)
} }

View file

@ -122,7 +122,9 @@ func uniqueCompound(p config.Person, all []config.Person) bool {
// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only // catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only
// allowed outputs; variants and examples are presented as illustrations, never as // allowed outputs; variants and examples are presented as illustrations, never as
// data to extract. // data to extract.
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string) string { // notes are user-authored corrections appended verbatim as a final section; pass nil
// for none.
func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string, notes []string) string {
var b strings.Builder var b strings.Builder
b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ") b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ")
@ -178,6 +180,18 @@ func BuildSystemPrompt(persons []config.Person, categories []config.Category, to
b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n") b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n")
b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n") b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n")
// User corrections learned from past misreads (see the AI tab). Appended last so
// they take precedence over the general guidance above.
if len(notes) > 0 {
b.WriteString("\nADDITIONAL CORRECTIONS — learned from past mistakes on these specific receipts; apply them:\n")
for _, n := range notes {
n = strings.TrimSpace(n)
if n != "" {
fmt.Fprintf(&b, " - %s\n", n)
}
}
}
return b.String() return b.String()
} }

View file

@ -69,19 +69,19 @@ func TestBuildSystemPrompt(t *testing.T) {
{Name: "Pharmacy", Examples: []string{"CVS", "Rx"}}, {Name: "Pharmacy", Examples: []string{"CVS", "Rx"}},
{Name: "Other"}, {Name: "Other"},
} }
p := BuildSystemPrompt(persons, categories, "2026-06-17") p := BuildSystemPrompt(persons, categories, "2026-06-17", []string{"Treat handwritten totals as authoritative."})
for _, want := range []string{ for _, want := range []string{
"Jean-Michel Tremblay", // canonical label "Jean-Michel Tremblay", // canonical label
"Lynna Nguyen", "Lynna Nguyen",
"Pharmacy", "Pharmacy",
"CVS", // example injected "CVS", // example injected
"2026-06-17", // today "2026-06-17", // today
"AMBIGUOUS", // the ambiguity rule "AMBIGUOUS", // the ambiguity rule
"IGNORE everyone", // ignore-other-people rule "IGNORE everyone", // ignore-other-people rule
"null", // unknown handling "null", // unknown handling
"raw_name", // raw echo "raw_name", // raw echo
"CLOSEST to today", // date tie-breaker "CLOSEST to today", // date tie-breaker
} { } {
if !strings.Contains(p, want) { if !strings.Contains(p, want) {
t.Errorf("system prompt missing %q", want) t.Errorf("system prompt missing %q", want)

View file

@ -0,0 +1,130 @@
package storage
import (
"database/sql"
"fmt"
"strings"
"time"
)
// Note is one classifier correction note. Notes are global free-text lines appended
// to the classifier prompt. The table is temporal: an edit soft-deletes the old row
// and inserts a new one, so the set of notes active at any past time is recoverable.
type Note struct {
ID int64
Text string
CreatedAt time.Time
}
// defaultNotes seed the ai_notes table on first run (when it is completely empty).
// They carry no PII and are also documented in the README; users curate from here.
var defaultNotes = []string{
`Amounts that use a comma as the decimal separator (e.g. "12,50") mean 12.50, not 1250.`,
`When both a service/visit date and a separate statement, print, or due date appear, use the service date.`,
`"Patient Pay", "You Paid", "Amount Due", and "Patient Responsibility" are the amount actually paid — prefer them over subtotals or insurance-covered amounts.`,
}
// seedNotesIfEmpty inserts the default notes only when the table has no rows at all,
// so a deliberately-deleted default does not resurrect on the next restart.
func seedNotesIfEmpty(db *sql.DB) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM ai_notes`).Scan(&n); err != nil {
return fmt.Errorf("count ai_notes: %w", err)
}
if n > 0 {
return nil
}
now := time.Now().UTC().Format(rfc3339)
for _, t := range defaultNotes {
if _, err := db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, t, now); err != nil {
return fmt.Errorf("seed ai_notes: %w", err)
}
}
return nil
}
// ListActiveNotes returns the live notes (not soft-deleted), oldest first — the set
// appended to the classifier prompt.
func (s *Store) ListActiveNotes() ([]Note, error) {
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
WHERE deleted_at IS NULL ORDER BY created_at, id`)
}
// NotesCreatedAfter returns the live notes created strictly after t — the
// "rules added since this failure" shown when reviewing a miss.
func (s *Store) NotesCreatedAfter(t time.Time) ([]Note, error) {
return s.queryNotes(`SELECT id, text, created_at FROM ai_notes
WHERE deleted_at IS NULL AND created_at > ? ORDER BY created_at, id`,
t.UTC().Format(rfc3339))
}
func (s *Store) queryNotes(q string, args ...any) ([]Note, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query notes: %w", err)
}
defer rows.Close()
var out []Note
for rows.Next() {
var n Note
var created string
if err := rows.Scan(&n.ID, &n.Text, &created); err != nil {
return nil, fmt.Errorf("scan note: %w", err)
}
n.CreatedAt, _ = time.Parse(rfc3339, created)
out = append(out, n)
}
return out, rows.Err()
}
// AddNote inserts a new active note and returns its id.
func (s *Store) AddNote(text string) (int64, error) {
text = strings.TrimSpace(text)
if text == "" {
return 0, fmt.Errorf("note cannot be empty")
}
res, err := s.db.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`,
text, time.Now().UTC().Format(rfc3339))
if err != nil {
return 0, fmt.Errorf("add note: %w", err)
}
return res.LastInsertId()
}
// DeleteNote soft-deletes a note (preserving history).
func (s *Store) DeleteNote(id int64) error {
_, err := s.db.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
time.Now().UTC().Format(rfc3339), id)
if err != nil {
return fmt.Errorf("delete note: %w", err)
}
return nil
}
// EditNote changes a note's text by soft-deleting the old row and inserting a new
// one, so the temporal history (what was active when) is preserved. Returns the new id.
func (s *Store) EditNote(id int64, text string) (int64, error) {
text = strings.TrimSpace(text)
if text == "" {
return 0, fmt.Errorf("note cannot be empty")
}
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin edit note: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC().Format(rfc3339)
if _, err := tx.Exec(`UPDATE ai_notes SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, now, id); err != nil {
return 0, fmt.Errorf("retire old note: %w", err)
}
res, err := tx.Exec(`INSERT INTO ai_notes(text, created_at) VALUES (?, ?)`, text, now)
if err != nil {
return 0, fmt.Errorf("insert edited note: %w", err)
}
newID, err := res.LastInsertId()
if err != nil {
return 0, err
}
return newID, tx.Commit()
}

View file

@ -0,0 +1,106 @@
package storage
import (
"path/filepath"
"testing"
"time"
)
func notesTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "notes.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestNotes_SeededOnFirstOpen(t *testing.T) {
s := notesTestStore(t)
notes, err := s.ListActiveNotes()
if err != nil {
t.Fatal(err)
}
if len(notes) != len(defaultNotes) {
t.Fatalf("seeded %d notes, want %d", len(notes), len(defaultNotes))
}
}
func TestNotes_DeletedDefaultDoesNotResurrect(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "n.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
all, _ := s.ListActiveNotes()
if err := s.DeleteNote(all[0].ID); err != nil {
t.Fatal(err)
}
s.Close()
// Reopen: seeding must NOT run again (table is non-empty thanks to history).
s2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
notes, _ := s2.ListActiveNotes()
if len(notes) != len(defaultNotes)-1 {
t.Errorf("after delete+reopen got %d active notes, want %d", len(notes), len(defaultNotes)-1)
}
}
func TestNotes_EditKeepsHistory(t *testing.T) {
s := notesTestStore(t)
id, err := s.AddNote("original")
if err != nil {
t.Fatal(err)
}
newID, err := s.EditNote(id, "revised")
if err != nil {
t.Fatal(err)
}
if newID == id {
t.Error("edit should create a new row id")
}
active, _ := s.ListActiveNotes()
var texts []string
for _, n := range active {
texts = append(texts, n.Text)
}
if contains(texts, "original") {
t.Error("old text should no longer be active")
}
if !contains(texts, "revised") {
t.Error("revised text should be active")
}
}
func TestNotes_CreatedAfter(t *testing.T) {
s := notesTestStore(t)
cutoff := time.Now().UTC()
// created_at has second resolution (RFC3339); make sure the new note sorts after.
time.Sleep(1100 * time.Millisecond)
if _, err := s.AddNote("late note"); err != nil {
t.Fatal(err)
}
after, err := s.NotesCreatedAfter(cutoff)
if err != nil {
t.Fatal(err)
}
if len(after) != 1 || after[0].Text != "late note" {
t.Fatalf("NotesCreatedAfter = %v, want just the late note", after)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}

View file

@ -0,0 +1,123 @@
package storage
import (
"database/sql"
"fmt"
"time"
)
// ClassificationRow is a stored AI classification of a receipt, joined with the
// receipt's final field values so the caller can derive which fields the user
// overrode (a "miss"). The response blob is opaque diagnostic data here — the web
// layer parses it; storage never interprets it.
type ClassificationRow struct {
ReceiptID string
Model string
ResponseJSON string
CreatedAt time.Time
Reviewed bool
ReviewedAt *time.Time
Resolution string
// Final receipt values, for deriving overrides and display.
FinalAmountCents int64
FinalReceiptDate time.Time
FinalCategoryID int64
FinalPersonID *int64
}
// InsertClassification records the AI suggestion (its response blob + model) for a
// receipt. One row per AI-run upload; skip-AI/disabled uploads insert nothing.
func (s *Store) InsertClassification(receiptID, model, responseJSON string, createdAt time.Time) error {
_, err := s.db.Exec(
`INSERT INTO classifications(receipt_id, model, response_json, created_at)
VALUES (?, ?, ?, ?)`,
receiptID, model, responseJSON, createdAt.UTC().Format(rfc3339))
if err != nil {
return fmt.Errorf("insert classification: %w", err)
}
return nil
}
const classificationSelect = `
SELECT c.receipt_id, c.model, c.response_json, c.created_at, c.reviewed, c.reviewed_at, c.resolution,
r.amount_cents, r.receipt_date, r.category_id, r.person_id
FROM classifications c
JOIN receipts r ON r.id = c.receipt_id`
// ListUnreviewedClassifications returns not-yet-reviewed classifications of live
// receipts, newest first. Whether each is an actual "miss" is derived by the caller
// from the blob vs. the final fields.
func (s *Store) ListUnreviewedClassifications() ([]ClassificationRow, error) {
rows, err := s.db.Query(classificationSelect +
` WHERE c.reviewed = 0 AND r.deleted_at IS NULL ORDER BY c.created_at DESC`)
if err != nil {
return nil, fmt.Errorf("list classifications: %w", err)
}
defer rows.Close()
var out []ClassificationRow
for rows.Next() {
c, err := scanClassification(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetClassification returns the classification for one receipt.
func (s *Store) GetClassification(receiptID string) (ClassificationRow, error) {
return scanClassification(s.db.QueryRow(classificationSelect+` WHERE c.receipt_id = ?`, receiptID))
}
func scanClassification(sc rowScanner) (ClassificationRow, error) {
var c ClassificationRow
var createdAt, receiptDate string
var reviewed int64
var reviewedAt, resolution sql.NullString
var personID sql.NullInt64
if err := sc.Scan(&c.ReceiptID, &c.Model, &c.ResponseJSON, &createdAt, &reviewed, &reviewedAt, &resolution,
&c.FinalAmountCents, &receiptDate, &c.FinalCategoryID, &personID); err != nil {
return ClassificationRow{}, fmt.Errorf("scan classification: %w", err)
}
c.CreatedAt, _ = time.Parse(rfc3339, createdAt)
c.FinalReceiptDate, _ = time.Parse(rfc3339, receiptDate)
c.Reviewed = reviewed != 0
if reviewedAt.Valid {
if t, err := time.Parse(rfc3339, reviewedAt.String); err == nil {
c.ReviewedAt = &t
}
}
if resolution.Valid {
c.Resolution = resolution.String
}
if personID.Valid {
c.FinalPersonID = &personID.Int64
}
return c, nil
}
// MarkClassificationReviewed flags a classification reviewed with the given
// resolution and links the notes the user credited with fixing it.
func (s *Store) MarkClassificationReviewed(receiptID, resolution string, fixNoteIDs []int64) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin review: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE classifications SET reviewed = 1, reviewed_at = ?, resolution = ? WHERE receipt_id = ?`,
time.Now().UTC().Format(rfc3339), resolution, receiptID); err != nil {
return fmt.Errorf("mark reviewed: %w", err)
}
for _, nid := range fixNoteIDs {
if _, err := tx.Exec(
`INSERT OR IGNORE INTO miss_fixes(receipt_id, note_id) VALUES (?, ?)`,
receiptID, nid); err != nil {
return fmt.Errorf("link fix note: %w", err)
}
}
return tx.Commit()
}

View file

@ -0,0 +1,69 @@
package storage
import (
"path/filepath"
"testing"
"time"
)
func classifyTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "c.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestClassifications_InsertListReview(t *testing.T) {
s := classifyTestStore(t)
insertReceipt(t, s, "r1")
if err := s.InsertClassification("r1", "haiku", `{"model":"haiku"}`, time.Now().UTC()); err != nil {
t.Fatal(err)
}
rows, err := s.ListUnreviewedClassifications()
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ReceiptID != "r1" || rows[0].Model != "haiku" {
t.Fatalf("ListUnreviewedClassifications = %+v", rows)
}
if rows[0].FinalAmountCents != 100 { // insertReceipt sets 100 cents
t.Errorf("joined final amount = %d, want 100", rows[0].FinalAmountCents)
}
// Review it, crediting a note as the fix.
noteID, _ := s.AddNote("the fix")
if err := s.MarkClassificationReviewed("r1", "fixed", []int64{noteID}); err != nil {
t.Fatal(err)
}
rows, _ = s.ListUnreviewedClassifications()
if len(rows) != 0 {
t.Errorf("reviewed row still in unreviewed queue: %+v", rows)
}
got, err := s.GetClassification("r1")
if err != nil {
t.Fatal(err)
}
if !got.Reviewed || got.Resolution != "fixed" || got.ReviewedAt == nil {
t.Errorf("review state not persisted: %+v", got)
}
}
func TestClassifications_DeletedReceiptDropsFromQueue(t *testing.T) {
s := classifyTestStore(t)
insertReceipt(t, s, "r1")
if err := s.InsertClassification("r1", "m", `{}`, time.Now().UTC()); err != nil {
t.Fatal(err)
}
if err := s.SoftDelete("r1"); err != nil {
t.Fatal(err)
}
rows, _ := s.ListUnreviewedClassifications()
if len(rows) != 0 {
t.Errorf("soft-deleted receipt should not appear in review queue: %+v", rows)
}
}

View file

@ -65,6 +65,37 @@ CREATE TABLE IF NOT EXISTS attachments (
deleted_at TEXT deleted_at TEXT
); );
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL COLLATE NOCASE UNIQUE
);
CREATE TABLE IF NOT EXISTS receipt_tags (
receipt_id TEXT NOT NULL REFERENCES receipts(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (receipt_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_receipt_tags_tag ON receipt_tags(tag_id);
CREATE TABLE IF NOT EXISTS ai_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE IF NOT EXISTS classifications (
receipt_id TEXT PRIMARY KEY REFERENCES receipts(id),
model TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL,
reviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
resolution TEXT
);
CREATE INDEX IF NOT EXISTS idx_classifications_reviewed ON classifications(reviewed);
CREATE TABLE IF NOT EXISTS miss_fixes (
receipt_id TEXT NOT NULL REFERENCES receipts(id),
note_id INTEGER NOT NULL REFERENCES ai_notes(id),
PRIMARY KEY (receipt_id, note_id)
);
` `
// Open opens (creating if needed) the SQLite database at path, applies the schema, // Open opens (creating if needed) the SQLite database at path, applies the schema,
@ -97,6 +128,10 @@ func Open(path string) (*Store, error) {
return nil, fmt.Errorf("seed categories: %w", err) return nil, fmt.Errorf("seed categories: %w", err)
} }
} }
if err := seedNotesIfEmpty(db); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil return &Store{db: db}, nil
} }

90
internal/storage/tags.go Normal file
View file

@ -0,0 +1,90 @@
package storage
import (
"fmt"
"strings"
)
// ListTags returns all tags ordered alphabetically (case-insensitive), same shape
// as categories/people.
func (s *Store) ListTags() ([]Lookup, error) { return s.listLookup("tags") }
// SetReceiptTags links the given tag labels to a receipt, creating any tag that
// does not yet exist (matched case-insensitively via the column's NOCASE
// collation). Labels are trimmed; blanks and case-insensitive duplicates are
// collapsed. It first clears the receipt's existing links, so it applies the full
// desired set idempotently. Runs in one transaction so a receipt's tag set is
// applied atomically.
func (s *Store) SetReceiptTags(receiptID string, labels []string) error {
clean := normalizeLabels(labels)
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin set tags: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM receipt_tags WHERE receipt_id = ?`, receiptID); err != nil {
return fmt.Errorf("clear receipt tags: %w", err)
}
for _, label := range clean {
// Create-if-missing: NOCASE-unique label means a differently-cased dup is
// ignored, and the SELECT then resolves to the existing row.
if _, err := tx.Exec(`INSERT OR IGNORE INTO tags(label) VALUES (?)`, label); err != nil {
return fmt.Errorf("upsert tag %q: %w", label, err)
}
var id int64
if err := tx.QueryRow(`SELECT id FROM tags WHERE label = ?`, label).Scan(&id); err != nil {
return fmt.Errorf("find tag %q: %w", label, err)
}
if _, err := tx.Exec(
`INSERT OR IGNORE INTO receipt_tags(receipt_id, tag_id) VALUES (?, ?)`,
receiptID, id); err != nil {
return fmt.Errorf("link tag %q: %w", label, err)
}
}
return tx.Commit()
}
// ListReceiptTags returns a receipt's tag labels, alphabetical (case-insensitive).
func (s *Store) ListReceiptTags(receiptID string) ([]string, error) {
rows, err := s.db.Query(
`SELECT t.label FROM receipt_tags rt
JOIN tags t ON t.id = rt.tag_id
WHERE rt.receipt_id = ?
ORDER BY t.label COLLATE NOCASE`, receiptID)
if err != nil {
return nil, fmt.Errorf("list receipt tags: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var l string
if err := rows.Scan(&l); err != nil {
return nil, fmt.Errorf("scan receipt tag: %w", err)
}
out = append(out, l)
}
return out, rows.Err()
}
// normalizeLabels trims each label, drops blanks, and removes case-insensitive
// duplicates while preserving the first-seen casing and order.
func normalizeLabels(labels []string) []string {
seen := map[string]bool{}
var out []string
for _, l := range labels {
l = strings.TrimSpace(l)
if l == "" {
continue
}
key := strings.ToLower(l)
if seen[key] {
continue
}
seen[key] = true
out = append(out, l)
}
return out
}

View file

@ -0,0 +1,107 @@
package storage
import (
"path/filepath"
"testing"
"time"
"maisym.com/hsa/internal/receipt"
)
func tagTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "tags.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func insertReceipt(t *testing.T, s *Store, id string) {
t.Helper()
cats, err := s.ListCategories()
if err != nil || len(cats) == 0 {
t.Fatalf("categories: %v", err)
}
rec := receipt.Receipt{
ID: id, UploadedBy: "jm", UploadedAt: time.Now(), ReceiptDate: time.Now(),
AmountCents: 100, CategoryID: cats[0].ID, FilePath: "x", ImageData: []byte("x"),
FileSizeBytes: 1, OriginalFilename: "x.png", MimeType: "image/png",
}
if err := s.Insert(rec); err != nil {
t.Fatalf("insert receipt: %v", err)
}
}
func TestSetReceiptTags_CreatesLinksAndDedups(t *testing.T) {
s := tagTestStore(t)
insertReceipt(t, s, "r1")
// Mixed casing + blanks + a dup that differs only by case must collapse to two.
if err := s.SetReceiptTags("r1", []string{" Dental ", "tax-2026", "DENTAL", ""}); err != nil {
t.Fatal(err)
}
got, err := s.ListReceiptTags("r1")
if err != nil {
t.Fatal(err)
}
// Alphabetical (case-insensitive); first-seen casing "Dental" is preserved.
want := []string{"Dental", "tax-2026"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("ListReceiptTags = %v, want %v", got, want)
}
tags, err := s.ListTags()
if err != nil {
t.Fatal(err)
}
if len(tags) != 2 {
t.Errorf("catalog has %d tags, want 2 (no case-dup): %v", len(tags), tags)
}
}
func TestSetReceiptTags_ReusesExistingTagAcrossReceipts(t *testing.T) {
s := tagTestStore(t)
insertReceipt(t, s, "r1")
insertReceipt(t, s, "r2")
if err := s.SetReceiptTags("r1", []string{"Vision"}); err != nil {
t.Fatal(err)
}
// Same label, different casing, on another receipt: must reuse the one tag row.
if err := s.SetReceiptTags("r2", []string{"vision"}); err != nil {
t.Fatal(err)
}
tags, err := s.ListTags()
if err != nil {
t.Fatal(err)
}
if len(tags) != 1 {
t.Fatalf("catalog has %d tags, want 1 shared: %v", len(tags), tags)
}
if r1, _ := s.ListReceiptTags("r1"); len(r1) != 1 {
t.Errorf("r1 tags = %v, want 1", r1)
}
if r2, _ := s.ListReceiptTags("r2"); len(r2) != 1 {
t.Errorf("r2 tags = %v, want 1", r2)
}
}
func TestSetReceiptTags_ReplacesPriorSet(t *testing.T) {
s := tagTestStore(t)
insertReceipt(t, s, "r1")
if err := s.SetReceiptTags("r1", []string{"a", "b"}); err != nil {
t.Fatal(err)
}
if err := s.SetReceiptTags("r1", []string{"b", "c"}); err != nil {
t.Fatal(err)
}
got, _ := s.ListReceiptTags("r1")
if len(got) != 2 || got[0] != "b" || got[1] != "c" {
t.Errorf("after replace, tags = %v, want [b c]", got)
}
}

273
internal/web/ai.go Normal file
View file

@ -0,0 +1,273 @@
package web
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"maisym.com/hsa/internal/receipt"
"maisym.com/hsa/internal/storage"
)
// --- AI tab: classifier correction notes + failure review ---
type aiView struct {
Notes []storage.Note
Misses []missSummary // unreviewed failures
Prompt string // live assembled system prompt, or "" when unavailable
PromptNote string // why the prompt is unavailable (classification off)
Error string
}
// missSummary is one unreviewed failure in the review queue.
type missSummary struct {
ReceiptID string
When string
Amount string
Model string
Fields string // comma-joined names of the overridden fields
}
func (s *Server) handleAI(w http.ResponseWriter, r *http.Request) {
notes, err := s.store.ListActiveNotes()
if err != nil {
s.serverError(w, "list notes", err)
return
}
rows, err := s.store.ListUnreviewedClassifications()
if err != nil {
s.serverError(w, "list classifications", err)
return
}
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
var misses []missSummary
for _, c := range rows {
ovs := deriveOverrides(c, cats, people)
var names []string
for _, o := range ovs {
if o.Overridden {
names = append(names, o.Field)
}
}
if len(names) == 0 {
continue // not a miss — the AI got every field right
}
misses = append(misses, missSummary{
ReceiptID: c.ReceiptID,
When: c.CreatedAt.Local().Format(dateLayout),
Amount: dollars(c.FinalAmountCents),
Model: c.Model,
Fields: strings.Join(names, ", "),
})
}
view := aiView{Notes: notes, Misses: misses, Error: r.URL.Query().Get("error")}
if s.classifier != nil {
var texts []string
for _, n := range notes {
texts = append(texts, n.Text)
}
view.Prompt = s.classifier.PromptPreview(time.Now(), texts)
} else {
view.PromptNote = "Classification is disabled (no API key configured), so the live prompt is unavailable."
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := aiPage.ExecuteTemplate(w, "base", view); err != nil {
s.serverError(w, "render ai", err)
}
}
func (s *Server) handleAddNote(w http.ResponseWriter, r *http.Request) {
if _, err := s.store.AddNote(strings.TrimSpace(r.FormValue("text"))); err != nil {
redirectAI(w, r, "Could not add the note (it may be empty).")
return
}
redirectAI(w, r, "")
}
func (s *Server) handleEditNote(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil {
redirectAI(w, r, "Invalid note id.")
return
}
if _, err := s.store.EditNote(id, strings.TrimSpace(r.FormValue("text"))); err != nil {
redirectAI(w, r, "Could not save the note (it may be empty).")
return
}
redirectAI(w, r, "")
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil {
redirectAI(w, r, "Invalid note id.")
return
}
if err := s.store.DeleteNote(id); err != nil {
redirectAI(w, r, "Could not delete the note.")
return
}
redirectAI(w, r, "")
}
// reviewView is the single-failure review page.
type reviewView struct {
ReceiptID string
When string
Model string
Fields []fieldOverride
SinceNotes []storage.Note // notes added after this failure (candidate fixes)
}
func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
c, err := s.store.GetClassification(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
since, err := s.store.NotesCreatedAfter(c.CreatedAt)
if err != nil {
s.serverError(w, "notes since", err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := reviewPage.ExecuteTemplate(w, "base", reviewView{
ReceiptID: c.ReceiptID,
When: c.CreatedAt.Local().Format("2006-01-02 15:04"),
Model: c.Model,
Fields: deriveOverrides(c, cats, people),
SinceNotes: since,
}); err != nil {
s.serverError(w, "render review", err)
}
}
func (s *Server) handleReviewSubmit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
var fixIDs []int64
for _, v := range r.Form["fix"] {
if nid, err := strconv.ParseInt(v, 10, 64); err == nil {
fixIDs = append(fixIDs, nid)
}
}
resolution := "unresolved"
if len(fixIDs) > 0 {
resolution = "fixed"
}
if err := s.store.MarkClassificationReviewed(id, resolution, fixIDs); err != nil {
s.serverError(w, "mark reviewed", err)
return
}
http.Redirect(w, r, "/ai", http.StatusSeeOther)
}
func redirectAI(w http.ResponseWriter, r *http.Request, errMsg string) {
target := "/ai"
if errMsg != "" {
target += "?error=" + url.QueryEscape(errMsg)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// fieldOverride is one of the four AI-suggested fields, with the suggestion, the
// final value, and whether the user overrode it.
type fieldOverride struct {
Field string
Suggested string
Final string
Overridden bool
}
// deriveOverrides compares the stored AI suggestion blob against the receipt's final
// values to determine which of the four fields the user changed. A field the AI left
// null/empty that the user then filled counts as an override (the AI missed it).
func deriveOverrides(c storage.ClassificationRow, cats, people []storage.Lookup) []fieldOverride {
var cr classifyResponse
_ = json.Unmarshal([]byte(c.ResponseJSON), &cr)
finalAmount := dollars(c.FinalAmountCents)
finalDate := c.FinalReceiptDate.Format(dateLayout)
finalCat := labelFor(c.FinalCategoryID, cats)
finalWho := whoLabel(c.FinalPersonID, people)
return []fieldOverride{
{Field: "amount", Suggested: strPtr(cr.Amount), Final: finalAmount,
Overridden: amountOverridden(cr.Amount, c.FinalAmountCents)},
{Field: "date", Suggested: strPtr(cr.Date), Final: finalDate,
Overridden: dateOverridden(cr.Date, c.FinalReceiptDate)},
{Field: "category", Suggested: lookupOr(cr.CategoryID, cats, cr.Category), Final: finalCat,
Overridden: int64PtrOverridden(cr.CategoryID, &c.FinalCategoryID)},
{Field: "who", Suggested: lookupOr(cr.PersonID, people, cr.Person), Final: finalWho,
Overridden: int64PtrOverridden(cr.PersonID, c.FinalPersonID)},
}
}
func amountOverridden(suggested *string, finalCents int64) bool {
if suggested == nil {
return true
}
cents, err := receipt.ParseAmountCents(*suggested)
if err != nil {
return true
}
return cents != finalCents
}
func dateOverridden(suggested *string, final time.Time) bool {
if suggested == nil {
return true
}
d, err := time.Parse(dateLayout, strings.TrimSpace(*suggested))
if err != nil {
return true
}
return d.Year() != final.Year() || d.Month() != final.Month() || d.Day() != final.Day()
}
func int64PtrOverridden(suggested, final *int64) bool {
if suggested == nil || final == nil {
return suggested != final // both nil → not overridden; one nil → overridden
}
return *suggested != *final
}
func strPtr(p *string) string {
if p == nil || strings.TrimSpace(*p) == "" {
return "—"
}
return *p
}
// lookupOr renders a suggested lookup id as its label, falling back to a label the
// blob already carried, then to "—".
func lookupOr(id *int64, set []storage.Lookup, fallback string) string {
if id != nil {
if l := labelFor(*id, set); l != "" {
return l
}
}
if strings.TrimSpace(fallback) != "" {
return fallback
}
return "—"
}

144
internal/web/ai_test.go Normal file
View file

@ -0,0 +1,144 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"maisym.com/hsa/internal/auth"
)
// uploadWithClassifyBlob posts a receipt plus a round-tripped AI suggestion blob.
func uploadWithClassifyBlob(t *testing.T, s *Server, amount, classifyJSON string) {
t.Helper()
body, ct := multipartUpload(t, map[string]string{
"amount": amount,
"receipt_date": "2026-06-01",
"category_id": aCategoryID(t, s),
"classify_json": classifyJSON,
}, "receipt", "r.png", fakePNG())
req := httptest.NewRequest(http.MethodPost, "/upload", body)
req.Header.Set("Content-Type", ct)
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("upload status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAI_MissRecordedAndReviewable(t *testing.T) {
s := testServerWithStore(t)
// AI suggested $99.99; user submitted $12.34 → an amount miss.
blob := `{"amount":"99.99","date":"2026-06-01","category_id":null,"person_id":null,"model":"haiku-test"}`
uploadWithClassifyBlob(t, s, "12.34", blob)
// Stored classification exists.
rows, err := s.store.ListUnreviewedClassifications()
if err != nil || len(rows) != 1 {
t.Fatalf("ListUnreviewedClassifications: err=%v len=%d", err, len(rows))
}
id := rows[0].ReceiptID
// /ai lists it as a miss touching "amount".
page := get(t, s, "/ai").Body.String()
if !strings.Contains(page, "haiku-test") || !strings.Contains(page, "amount") {
t.Errorf("/ai missing the amount miss: %s", page)
}
// Review page shows the AI guess vs the entered value.
rv := get(t, s, "/ai/review/"+id).Body.String()
if !strings.Contains(rv, "99.99") || !strings.Contains(rv, "12.34") {
t.Errorf("review page missing guess/final: %s", rv)
}
// Submitting the review (no fix ticked) closes it as unresolved and clears the queue.
req := httptest.NewRequest(http.MethodPost, "/ai/review/"+id, strings.NewReader(url.Values{}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("review submit status=%d", rec.Code)
}
left, _ := s.store.ListUnreviewedClassifications()
if len(left) != 0 {
t.Errorf("queue not cleared after review: %d left", len(left))
}
if c, _ := s.store.GetClassification(id); c.Resolution != "unresolved" {
t.Errorf("resolution = %q, want unresolved", c.Resolution)
}
}
func TestAI_NoMissWhenSuggestionMatches(t *testing.T) {
s := testServerWithStore(t)
cat := aCategoryID(t, s)
// AI nailed every field the user submitted → not a miss.
blob := `{"amount":"12.34","date":"2026-06-01","category_id":` + cat + `,"person_id":null,"model":"m"}`
uploadWithClassifyBlob(t, s, "12.34", blob)
page := get(t, s, "/ai").Body.String()
if !strings.Contains(page, "No misreads to review") {
t.Errorf("expected no-misreads message, got: %s", page)
}
}
func TestAuth_StaleSessionRedirects(t *testing.T) {
s := testServerWithStore(t)
// A session issued well beyond the TTL must be rejected server-side even though
// the cookie value itself is a valid (untampered) sealed token.
old := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"},
IssuedAt: time.Now().Add(-13 * time.Hour)}
v, err := auth.EncodeSession(old, s.cfg.SessionKey)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: v})
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Errorf("stale session: status=%d location=%q, want 302 -> /login", rec.Code, rec.Header().Get("Location"))
}
}
func TestAI_NotesCRUD(t *testing.T) {
s := testServerWithStore(t)
post := func(path string, form url.Values) {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("%s status=%d", path, rec.Code)
}
}
post("/ai/notes", url.Values{"text": {"European dates are DD/MM"}})
notes, _ := s.store.ListActiveNotes()
var added int64
found := false
for _, n := range notes {
if n.Text == "European dates are DD/MM" {
added, found = n.ID, true
}
}
if !found {
t.Fatal("note not added")
}
post("/ai/notes/delete", url.Values{"id": {strconv.FormatInt(added, 10)}})
notes, _ = s.store.ListActiveNotes()
for _, n := range notes {
if n.ID == added {
t.Error("note not deleted")
}
}
}

View file

@ -27,6 +27,13 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
http.Redirect(w, r, "/login", http.StatusFound) http.Redirect(w, r, "/login", http.StatusFound)
return return
} }
// Server-side expiry: a cookie's MaxAge is client-enforced, so also reject a
// session older than its TTL — a captured cookie value can't be replayed forever.
if time.Since(sess.IssuedAt) > sessionTTL {
s.clearCookie(w, sessionCookie)
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), sessionKey, sess) ctx := context.WithValue(r.Context(), sessionKey, sess)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

113
internal/web/orientation.go Normal file
View file

@ -0,0 +1,113 @@
package web
import (
"bytes"
"encoding/binary"
"github.com/disintegration/imaging"
)
// normalizeOrientation returns image bytes that are physically upright.
//
// Phone cameras store a photo in the sensor's native orientation plus an EXIF
// "Orientation" tag telling viewers to rotate it. We bake that rotation into the
// pixels (and drop the tag) so the stored receipt is upright in every consumer —
// the browser, downloads, the AI classifier, a future PDF export — not just ones
// that happen to honor EXIF.
//
// It only acts when the orientation is actually KNOWN: a JPEG carrying an EXIF
// Orientation tag of 2..8. With no tag (the common case — there's no way to know
// which way is up) or an already-upright tag of 1, the original bytes are returned
// verbatim, so we neither guess nor needlessly recompress. PDFs and non-JPEG
// images pass through untouched, as does anything that fails to decode.
func normalizeOrientation(data []byte, mime string) []byte {
if mime != "image/jpeg" {
return data
}
switch exifOrientation(data) {
case 0, 1:
return data // no metadata to act on, or already upright
}
// imaging.AutoOrientation reads the EXIF tag and rotates/flips to upright.
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return data // undecodable — keep the original rather than lose it
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
return data
}
return buf.Bytes()
}
// exifOrientation returns the EXIF Orientation tag (1..8) of a JPEG, or 0 when the
// image has no such tag. It is a minimal, allocation-free scan: it walks the JPEG
// segment markers to the APP1 (Exif) block and reads tag 0x0112 from IFD0.
func exifOrientation(data []byte) int {
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 { // SOI
return 0
}
i := 2
for i+4 <= len(data) {
if data[i] != 0xFF {
return 0 // not at a marker boundary — give up
}
marker := data[i+1]
if marker == 0xDA || marker == 0xD9 { // SOS (image data begins) or EOI
return 0
}
segLen := int(data[i+2])<<8 | int(data[i+3])
if segLen < 2 {
return 0
}
segStart, segEnd := i+4, i+2+segLen
if segEnd > len(data) {
return 0
}
if marker == 0xE1 { // APP1
if o, ok := orientationFromAPP1(data[segStart:segEnd]); ok {
return o
}
}
i = segEnd
}
return 0
}
// orientationFromAPP1 reads the Orientation tag out of an APP1 segment's payload
// (the "Exif\0\0" header followed by a TIFF block). Returns (0, false) for any
// malformed or non-Exif segment.
func orientationFromAPP1(seg []byte) (int, bool) {
const hdr = "Exif\x00\x00"
if len(seg) < len(hdr)+8 || string(seg[:len(hdr)]) != hdr {
return 0, false
}
tiff := seg[len(hdr):]
var bo binary.ByteOrder
switch {
case tiff[0] == 'I' && tiff[1] == 'I':
bo = binary.LittleEndian
case tiff[0] == 'M' && tiff[1] == 'M':
bo = binary.BigEndian
default:
return 0, false
}
ifdOff := int(bo.Uint32(tiff[4:8]))
if ifdOff < 8 || ifdOff+2 > len(tiff) {
return 0, false
}
count := int(bo.Uint16(tiff[ifdOff : ifdOff+2]))
for k, entry := 0, ifdOff+2; k < count; k, entry = k+1, entry+12 {
if entry+12 > len(tiff) {
return 0, false
}
if bo.Uint16(tiff[entry:entry+2]) == 0x0112 { // Orientation
val := int(bo.Uint16(tiff[entry+8 : entry+10]))
if val >= 1 && val <= 8 {
return val, true
}
return 0, false
}
}
return 0, false
}

View file

@ -0,0 +1,115 @@
package web
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"testing"
"github.com/disintegration/imaging"
)
// jpegBytes encodes a w×h test image as JPEG (no EXIF).
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{uint8(x * 30), uint8(y * 30), 100, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// withOrientation splices a minimal big-endian EXIF APP1 segment carrying the
// given Orientation value into a JPEG (right after the SOI marker).
func withOrientation(t *testing.T, jpg []byte, orientation uint16) []byte {
t.Helper()
tiff := new(bytes.Buffer)
tiff.WriteString("MM") // big-endian
binary.Write(tiff, binary.BigEndian, uint16(0x002A)) // magic
binary.Write(tiff, binary.BigEndian, uint32(8)) // IFD0 at offset 8
binary.Write(tiff, binary.BigEndian, uint16(1)) // one entry
binary.Write(tiff, binary.BigEndian, uint16(0x0112)) // tag: Orientation
binary.Write(tiff, binary.BigEndian, uint16(3)) // type: SHORT
binary.Write(tiff, binary.BigEndian, uint32(1)) // count
binary.Write(tiff, binary.BigEndian, orientation) // value (in high 2 bytes)
binary.Write(tiff, binary.BigEndian, uint16(0)) // value padding
binary.Write(tiff, binary.BigEndian, uint32(0)) // next IFD: none
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
app1 := new(bytes.Buffer)
app1.Write([]byte{0xFF, 0xE1})
binary.Write(app1, binary.BigEndian, uint16(len(payload)+2)) // segment length
app1.Write(payload)
out := make([]byte, 0, len(jpg)+app1.Len())
out = append(out, jpg[:2]...) // SOI
out = append(out, app1.Bytes()...)
out = append(out, jpg[2:]...)
return out
}
func TestExifOrientation(t *testing.T) {
plain := jpegBytes(t, 4, 2)
if got := exifOrientation(plain); got != 0 {
t.Errorf("plain JPEG: orientation = %d, want 0 (no tag)", got)
}
for _, o := range []uint16{1, 3, 6, 8} {
tagged := withOrientation(t, plain, o)
if got := exifOrientation(tagged); got != int(o) {
t.Errorf("tagged orientation %d: got %d", o, got)
}
}
if got := exifOrientation([]byte("not a jpeg")); got != 0 {
t.Errorf("non-JPEG: orientation = %d, want 0", got)
}
}
func TestNormalizeOrientationPassThrough(t *testing.T) {
plain := jpegBytes(t, 4, 2)
// Non-JPEG mime → untouched.
png := []byte("fake png bytes")
if got := normalizeOrientation(png, "image/png"); !bytes.Equal(got, png) {
t.Error("png should pass through unchanged")
}
// JPEG without EXIF → untouched (can't know, don't guess or recompress).
if got := normalizeOrientation(plain, "image/jpeg"); !bytes.Equal(got, plain) {
t.Error("EXIF-less JPEG should pass through byte-for-byte")
}
// JPEG already upright (orientation 1) → untouched.
up := withOrientation(t, plain, 1)
if got := normalizeOrientation(up, "image/jpeg"); !bytes.Equal(got, up) {
t.Error("orientation-1 JPEG should pass through byte-for-byte")
}
}
func TestNormalizeOrientationRotates(t *testing.T) {
// Stored 4×2 (wide) image tagged orientation 6 ("rotate 90° CW to view") must
// come out upright — i.e. tall (2×4) — with the EXIF tag stripped.
wide := jpegBytes(t, 4, 2)
tagged := withOrientation(t, wide, 6)
out := normalizeOrientation(tagged, "image/jpeg")
if bytes.Equal(out, tagged) {
t.Fatal("orientation-6 JPEG should have been re-encoded upright")
}
if got := exifOrientation(out); got != 0 {
t.Errorf("output still carries orientation tag %d; want stripped (0)", got)
}
img, err := imaging.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
b := img.Bounds()
if b.Dx() != 2 || b.Dy() != 4 {
t.Errorf("output dims = %dx%d, want 2x4 (rotated upright)", b.Dx(), b.Dy())
}
}

View file

@ -27,7 +27,7 @@ type classifyResponse struct {
RawDate string `json:"raw_date"` RawDate string `json:"raw_date"`
RawAmount string `json:"raw_amount"` RawAmount string `json:"raw_amount"`
Model string `json:"model"` // model that ran, for display Model string `json:"model"` // model that ran, for display
CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown
} }
@ -62,8 +62,17 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType) http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType)
return return
} }
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType) // Append the user's correction notes to the prompt (best-effort).
var notes []string
if ns, err := s.store.ListActiveNotes(); err == nil {
for _, n := range ns {
notes = append(notes, n.Text)
}
}
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType, notes)
if err != nil { if err != nil {
s.serverError(w, "classify receipt", err) s.serverError(w, "classify receipt", err)
return return

View file

@ -134,6 +134,65 @@ a.button {
a.button.green { background: #137333; } a.button.green { background: #137333; }
a.button.blue { background: #1a73e8; } a.button.blue { background: #1a73e8; }
/* Tag picker: trigger button, summary, chip mosaic, and the modal it opens. */
button.tags-btn {
font-size: 1rem;
padding: .5rem .8rem;
border: 1px solid #1a73e8;
background: #fff;
color: #1a73e8;
border-radius: .4rem;
}
.tags-summary { margin-left: .5rem; font-size: .9rem; color: #555; }
#tags-hidden { display: none; }
.modal {
position: fixed;
inset: 0;
z-index: 10;
background: rgba(0, 0, 0, .45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal[hidden] { display: none; }
.modal-card {
background: #fff;
border-radius: .5rem;
padding: 1rem;
width: 100%;
max-width: 26rem;
max-height: 80vh;
overflow-y: auto;
}
.modal-card h2 { margin-top: 0; }
.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin: .5rem 0 1rem; }
.chip {
padding: .35rem .7rem;
border: 1px solid #ccc;
border-radius: 1rem;
background: #f1f3f4;
color: #333;
font-size: .9rem;
cursor: pointer;
}
.chip.sel { background: #137333; border-color: #137333; color: #fff; }
.newtag { display: flex; gap: .4rem; margin-bottom: 1rem; }
.newtag input { flex: 1; }
/* Tag chips shown read-only in the recent list. */
ul.recent .tags { margin-left: .15rem; }
ul.recent .tag {
display: inline-block;
font-size: .72rem;
background: #e8f0fe;
color: #1a56c4;
border-radius: .8rem;
padding: .02rem .4rem;
margin-left: .2rem;
}
/* Inline rename/add rows on the manage page. */ /* Inline rename/add rows on the manage page. */
form.row { form.row {
display: flex; display: flex;
@ -143,3 +202,32 @@ form.row {
form.row input { flex: 1; padding: .4rem; } form.row input { flex: 1; padding: .4rem; }
form.row button { padding: .4rem .7rem; } form.row button { padding: .4rem .7rem; }
.add input { border: 1px solid #137333; } .add input { border: 1px solid #137333; }
button.danger { background: #fce8e6; border: 1px solid #d93025; color: #a50e0e; }
/* AI correction notes: full-width, wrapping, auto-growing editors. */
form.note { margin: .75rem 0 1rem; }
form.note textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
padding: .5rem;
resize: vertical;
min-height: 3rem;
overflow: hidden;
}
form.note.add textarea { border: 1px solid #137333; }
form.note .note-actions { display: flex; gap: .4rem; margin-top: .35rem; }
form.note .note-actions button { padding: .4rem .9rem; }
/* AI tab: read-only prompt + miss-review table. */
pre.prompt {
white-space: pre-wrap;
word-break: break-word;
background: #f1f3f4;
padding: .6rem;
border-radius: .4rem;
font-size: .78rem;
max-height: 28rem;
overflow-y: auto;
}
table.tally tr.miss th, table.tally tr.miss td { background: #fef7e0; }

View file

@ -24,4 +24,6 @@ var (
exportPage = parsePage("export.html") exportPage = parsePage("export.html")
tallyPage = parsePage("tally.html") tallyPage = parsePage("tally.html")
recentPage = parsePage("recent.html") recentPage = parsePage("recent.html")
aiPage = parsePage("ai.html")
reviewPage = parsePage("ai_review.html")
) )

View file

@ -0,0 +1,60 @@
{{define "title"}}AI{{end}}
{{define "content"}}
<header>
<span><a href="/">← Add receipt</a></span>
<span><a href="/recent">Recent</a> · <a href="/tally">Tally</a> · <a href="/manage">Manage</a></span>
</header>
<h1>AI classifier</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<h2>Correction notes</h2>
<p><small>Free-text rules appended to the classifier prompt to fix recurring misreads. Editing a note keeps the old version in history.</small></p>
{{range .Notes}}
<form class="note" method="post" action="/ai/notes/edit">
<input type="hidden" name="id" value="{{.ID}}">
<textarea name="text" rows="2">{{.Text}}</textarea>
<div class="note-actions">
<button type="submit">Save</button>
<button type="submit" formaction="/ai/notes/delete" class="danger">Delete</button>
</div>
</form>
{{end}}
<form class="note add" method="post" action="/ai/notes">
<textarea name="text" rows="2" placeholder="New correction note…"></textarea>
<div class="note-actions"><button type="submit">Add note</button></div>
</form>
<script>
// Grow each note's textarea to fit its full text, so long notes are readable.
document.querySelectorAll("form.note textarea").forEach(function (ta) {
function fit() { ta.style.height = "auto"; ta.style.height = ta.scrollHeight + "px"; }
ta.addEventListener("input", fit);
fit();
});
</script>
<h2>Review misreads {{if .Misses}}({{len .Misses}}){{end}}</h2>
{{if not .Misses}}
<p><small>No misreads to review — the AI matched what you submitted (or nothing has been classified yet).</small></p>
{{else}}
<ul class="recent">
{{range .Misses}}
<li>
<a class="amt" href="/ai/review/{{.ReceiptID}}">${{.Amount}}</a>
<span class="meta">{{.When}} · changed: {{.Fields}}</span>
<span class="when">{{.Model}}</span>
</li>
{{end}}
</ul>
{{end}}
<h2>Current prompt</h2>
{{if .Prompt}}
<details>
<summary>View the exact system prompt sent to the model</summary>
<pre class="prompt">{{.Prompt}}</pre>
</details>
{{else}}
<p><small>{{.PromptNote}}</small></p>
{{end}}
{{end}}

View file

@ -0,0 +1,38 @@
{{define "title"}}Review misread{{end}}
{{define "content"}}
<header>
<span><a href="/ai">← AI</a></span>
<span>classified by {{.Model}} · {{.When}}</span>
</header>
<h1>Review misread</h1>
<p><a href="/receipt/{{.ReceiptID}}/file" target="_blank">Open the receipt image ↗</a></p>
<table class="tally">
<thead><tr><th>Field</th><th>AI guessed</th><th>You entered</th></tr></thead>
<tbody>
{{range .Fields}}
<tr{{if .Overridden}} class="miss"{{end}}>
<th>{{.Field}}</th>
<td>{{.Suggested}}</td>
<td>{{.Final}}{{if .Overridden}} ✎{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<p><small>Rows marked ✎ are where you overrode the AI.</small></p>
<form method="post" action="/ai/review/{{.ReceiptID}}">
<h2>What fixed it?</h2>
{{if .SinceNotes}}
<p><small>Notes added since this receipt was classified — tick any that address this misread:</small></p>
{{range .SinceNotes}}
<label class="check"><input type="checkbox" name="fix" value="{{.ID}}"> {{.Text}}</label>
{{end}}
{{else}}
<p><small>No notes were added after this receipt was classified. Add a correction note on the <a href="/ai">AI page</a> first if one would help, then come back — or close this as unresolved.</small></p>
{{end}}
<button type="submit" class="primary">Done reviewing</button>
<p><small>Ticking one or more notes marks this as fixed; ticking none closes it as unresolved.</small></p>
</form>
{{end}}

View file

@ -3,6 +3,7 @@
<p class="ok">✓ Receipt saved</p> <p class="ok">✓ Receipt saved</p>
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p> <p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}} {{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
{{if .Tags}}<p><small>Tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t}}{{end}}</small></p>{{end}}
<a class="button green" href="/">Add another</a> <a class="button green" href="/">Add another</a>
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p> <p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
{{end}} {{end}}

View file

@ -2,7 +2,7 @@
{{define "content"}} {{define "content"}}
<header> <header>
<span><a href="/">← Add receipt</a></span> <span><a href="/">← Add receipt</a></span>
<span><a href="/tally">Tally</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span> <span><a href="/tally">Tally</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
</header> </header>
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
<p class="tabs"> <p class="tabs">
@ -18,6 +18,7 @@
<a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a> <a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
<span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span> <span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span>
{{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}} {{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}}
{{if .Tags}}<span class="tags">{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</span>{{end}}
<span class="when">{{.When}}</span> <span class="when">{{.When}}</span>
</li> </li>
{{end}} {{end}}

View file

@ -2,7 +2,7 @@
{{define "content"}} {{define "content"}}
<header> <header>
<span>{{.Subject}}</span> <span>{{.Subject}}</span>
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span> <span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/ai">AI</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
</header> </header>
<h1>Add receipt</h1> <h1>Add receipt</h1>
{{range .Errors}}<div class="err">{{.}}</div>{{end}} {{range .Errors}}<div class="err">{{.}}</div>{{end}}
@ -30,6 +30,27 @@
<option value=""></option> <option value=""></option>
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}} {{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
</select> </select>
<label>Tags (optional)</label>
<button type="button" class="tags-btn" id="tags-btn">Add tags</button>
<span class="tags-summary" id="tags-summary"></span>
<div id="tags-hidden">
{{range .TagChips}}{{if .Selected}}<input type="hidden" name="tags" value="{{.Label}}">{{end}}{{end}}
</div>
<div class="modal" id="tags-modal" hidden>
<div class="modal-card">
<h2>Tags</h2>
<div class="chips" id="chip-list">
{{range .TagChips}}<button type="button" class="chip{{if .Selected}} sel{{end}}" data-label="{{.Label}}">{{.Label}}</button>{{end}}
</div>
<div class="newtag">
<input type="text" id="newtag-input" placeholder="Create a tag" autocomplete="off">
<button type="button" id="newtag-add">Add</button>
</div>
<button type="button" class="primary" id="tags-done">Done with tags</button>
</div>
</div>
<label for="attachments">Additional files (optional)</label> <label for="attachments">Additional files (optional)</label>
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple> <input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
<div class="dup-warn" id="dup-warn" hidden> <div class="dup-warn" id="dup-warn" hidden>
@ -37,9 +58,76 @@
<ul id="dup-list"></ul> <ul id="dup-list"></ul>
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label> <label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
</div> </div>
<input type="hidden" name="classify_json" id="classify_json">
<button type="submit" class="primary" id="submit-btn">Submit</button> <button type="submit" class="primary" id="submit-btn">Submit</button>
</form> </form>
<script>
// --- Tag picker: in-page modal with a chip mosaic. Selection is mirrored into
// hidden "tags" inputs so it submits with the rest of the form. ---
(function () {
var btn = document.getElementById("tags-btn");
var modal = document.getElementById("tags-modal");
var chipList = document.getElementById("chip-list");
var hidden = document.getElementById("tags-hidden");
var summary = document.getElementById("tags-summary");
var newInput = document.getElementById("newtag-input");
var newAdd = document.getElementById("newtag-add");
var done = document.getElementById("tags-done");
function chips() { return Array.prototype.slice.call(chipList.querySelectorAll(".chip")); }
function sync() {
var labels = chips().filter(function (c) { return c.classList.contains("sel"); })
.map(function (c) { return c.getAttribute("data-label"); });
hidden.innerHTML = "";
labels.forEach(function (l) {
var i = document.createElement("input");
i.type = "hidden"; i.name = "tags"; i.value = l;
hidden.appendChild(i);
});
summary.textContent = labels.length ? labels.length + " selected" : "none yet";
}
function find(label) {
var lower = label.toLowerCase();
return chips().filter(function (c) {
return c.getAttribute("data-label").toLowerCase() === lower;
})[0];
}
function addNew() {
var label = newInput.value.trim();
if (!label) return;
var existing = find(label);
if (existing) {
existing.classList.add("sel");
} else {
var c = document.createElement("button");
c.type = "button"; c.className = "chip sel";
c.setAttribute("data-label", label); c.textContent = label;
chipList.appendChild(c);
}
newInput.value = "";
sync();
}
chipList.addEventListener("click", function (e) {
var chip = e.target.closest(".chip");
if (!chip) return;
chip.classList.toggle("sel");
sync();
});
newAdd.addEventListener("click", addNew);
newInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); addNew(); }
});
btn.addEventListener("click", function () { modal.hidden = false; });
done.addEventListener("click", function () { modal.hidden = true; });
modal.addEventListener("click", function (e) {
if (e.target === modal) modal.hidden = true; // tap the backdrop to close
});
sync(); // reflect any pre-selected chips (e.g. carried across an error re-render)
})();
</script>
<script> <script>
(function () { (function () {
var form = document.getElementById("upload-form"); var form = document.getElementById("upload-form");
@ -102,11 +190,16 @@
skip.addEventListener("change", function () { skip.addEventListener("change", function () {
note.style.opacity = skip.checked ? "0.5" : ""; note.style.opacity = skip.checked ? "0.5" : "";
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand."; if (skip.checked) {
else note.innerHTML = baseNote; note.textContent = "AI auto-fill skipped — enter the fields by hand.";
setValue("classify_json", ""); // no AI suggestion to record
} else {
note.innerHTML = baseNote;
}
}); });
fileInput.addEventListener("change", function () { fileInput.addEventListener("change", function () {
setValue("classify_json", ""); // a new file invalidates any prior suggestion
var file = fileInput.files[0]; var file = fileInput.files[0];
if (!file || skip.checked) return; if (!file || skip.checked) return;
note.style.opacity = ""; note.style.opacity = "";
@ -125,6 +218,7 @@
if (d.date) setValue("receipt_date", d.date); if (d.date) setValue("receipt_date", d.date);
if (d.category_id != null) setValue("category_id", String(d.category_id)); if (d.category_id != null) setValue("category_id", String(d.category_id));
if (d.person_id != null) setValue("person_id", String(d.person_id)); if (d.person_id != null) setValue("person_id", String(d.person_id));
setValue("classify_json", JSON.stringify(d)); // round-trip for misread review
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : ""; var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit."; note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
// Pre-filled date+amount may match an existing receipt — re-check. // Pre-filled date+amount may match an existing receipt — re-check.

View file

@ -2,11 +2,14 @@ package web
import ( import (
"crypto/rand" "crypto/rand"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -31,6 +34,16 @@ type formView struct {
// shown as a footnote. ClassifyEnabled is false when no API key is configured. // shown as a footnote. ClassifyEnabled is false when no API key is configured.
ClassifyModel string ClassifyModel string
ClassifyEnabled bool ClassifyEnabled bool
// TagChips is the alphabetical tag picker: every known tag plus any unsaved
// labels carried across an error re-render, each marked selected or not.
TagChips []tagChip
}
// tagChip is one selectable tag in the upload form's picker.
type tagChip struct {
Label string
Selected bool
} }
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
@ -63,12 +76,53 @@ func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
func (s *Server) renderForm(w http.ResponseWriter, v formView) { func (s *Server) renderForm(w http.ResponseWriter, v formView) {
v.ClassifyModel = s.cfg.ClassifyModel v.ClassifyModel = s.cfg.ClassifyModel
v.ClassifyEnabled = s.classifier != nil v.ClassifyEnabled = s.classifier != nil
if v.TagChips == nil { // callers that carry selected tags set this themselves
if chips, err := s.tagChipsFor(nil); err == nil {
v.TagChips = chips
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil { if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
s.serverError(w, "render form", err) s.serverError(w, "render form", err)
} }
} }
// tagChipsFor builds the upload form's tag picker: every known tag in alphabetical
// order, plus any selected labels that aren't saved tags yet (a chip the user
// created before an error re-render), with the selected ones marked.
func (s *Server) tagChipsFor(selected []string) ([]tagChip, error) {
tags, err := s.store.ListTags()
if err != nil {
return nil, err
}
selSet := map[string]bool{}
for _, l := range selected {
if l = strings.TrimSpace(l); l != "" {
selSet[strings.ToLower(l)] = true
}
}
known := map[string]bool{}
var chips []tagChip
for _, t := range tags {
key := strings.ToLower(t.Label)
known[key] = true
chips = append(chips, tagChip{Label: t.Label, Selected: selSet[key]})
}
for _, l := range selected { // pending labels not yet persisted
l = strings.TrimSpace(l)
key := strings.ToLower(l)
if l == "" || known[key] {
continue
}
known[key] = true
chips = append(chips, tagChip{Label: l, Selected: true})
}
sort.Slice(chips, func(i, j int) bool {
return strings.ToLower(chips[i].Label) < strings.ToLower(chips[j].Label)
})
return chips, nil
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r.Context()) sess := sessionFrom(r.Context())
cats, people, err := s.lookups() cats, people, err := s.lookups()
@ -139,6 +193,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
mimeType = detectMime(data) mimeType = detectMime(data)
if !allowedMime(mimeType) { if !allowedMime(mimeType) {
errs = append(errs, "Only images and PDFs are allowed.") errs = append(errs, "Only images and PDFs are allowed.")
} else {
data = normalizeOrientation(data, mimeType) // bake in EXIF rotation
} }
} }
@ -148,8 +204,18 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
attachments, attErrs := readAttachments(r) attachments, attErrs := readAttachments(r)
errs = append(errs, attErrs...) errs = append(errs, attErrs...)
// Selected tags (existing labels and/or newly-typed ones) ride along as repeated
// "tags" fields. Resolved/created at save time so an abandoned upload adds none.
var tagLabels []string
if r.MultipartForm != nil {
tagLabels = r.MultipartForm.Value["tags"]
}
if len(errs) > 0 { if len(errs) > 0 {
view.Errors = errs view.Errors = errs
if chips, err := s.tagChipsFor(tagLabels); err == nil {
view.TagChips = chips // keep the user's tag selection across the re-render
}
s.renderForm(w, view) s.renderForm(w, view)
return return
} }
@ -182,6 +248,24 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
// Link tags now that the receipt id exists (create-if-missing by label).
if len(tagLabels) > 0 {
if err := s.store.SetReceiptTags(id, tagLabels); err != nil {
s.serverError(w, "save tags", err)
return
}
}
// Persist the AI suggestion the browser round-tripped, so misreads can be
// reviewed later (best-effort, diagnostic — never block the upload on it).
if blob := strings.TrimSpace(r.FormValue("classify_json")); blob != "" {
var cr classifyResponse
_ = json.Unmarshal([]byte(blob), &cr) // model only; blob stored verbatim
if err := s.store.InsertClassification(id, cr.Model, blob, time.Now().UTC()); err != nil {
log.Printf("warning: store classification for %s: %v", id, err)
}
}
// Save each attachment now that the parent receipt id exists, keyed to the // Save each attachment now that the parent receipt id exists, keyed to the
// receipt's date+amount so the files sort alongside it. // receipt's date+amount so the files sort alongside it.
var attachmentNames []string var attachmentNames []string
@ -208,6 +292,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
attachmentNames = append(attachmentNames, a.filename) attachmentNames = append(attachmentNames, a.filename)
} }
savedTags, _ := s.store.ListReceiptTags(id)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{ _ = confirmPage.ExecuteTemplate(w, "base", confirmView{
Category: labelFor(categoryID, cats), Category: labelFor(categoryID, cats),
@ -216,6 +302,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
Date: dateStr, Date: dateStr,
Filename: header.Filename, Filename: header.Filename,
Attachments: attachmentNames, Attachments: attachmentNames,
Tags: savedTags,
}) })
} }
@ -227,6 +314,7 @@ type confirmView struct {
Date string Date string
Filename string Filename string
Attachments []string Attachments []string
Tags []string
} }
// pendingAttachment is a validated extra file waiting to be saved with its receipt. // pendingAttachment is a validated extra file waiting to be saved with its receipt.
@ -264,6 +352,7 @@ func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) {
errs = append(errs, "Attachments must be images or PDFs.") errs = append(errs, "Attachments must be images or PDFs.")
continue continue
} }
adata = normalizeOrientation(adata, amime) // bake in EXIF rotation
out = append(out, pendingAttachment{ out = append(out, pendingAttachment{
data: adata, data: adata,
mimeType: amime, mimeType: amime,

View file

@ -11,6 +11,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"maisym.com/hsa/internal/auth" "maisym.com/hsa/internal/auth"
"maisym.com/hsa/internal/config" "maisym.com/hsa/internal/config"
@ -39,7 +40,7 @@ func testServerWithStore(t *testing.T) *Server {
func authCookie(t *testing.T, s *Server) *http.Cookie { func authCookie(t *testing.T, s *Server) *http.Cookie {
t.Helper() t.Helper()
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
v, err := auth.EncodeSession(sess, s.cfg.SessionKey) v, err := auth.EncodeSession(sess, s.cfg.SessionKey)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -213,6 +214,63 @@ func TestUpload_WithAttachments(t *testing.T) {
} }
} }
func TestUploadForm_RendersTagPicker(t *testing.T) {
s := testServerWithStore(t)
if err := s.store.SetReceiptTags("noop", nil); err != nil { // ensure table is queryable
t.Fatal(err)
}
rec := get(t, s, "/")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`id="tags-btn"`, `id="tags-modal"`, `Done with tags`} {
if !strings.Contains(body, want) {
t.Errorf("upload form missing %q", want)
}
}
}
func TestUpload_WithTags(t *testing.T) {
s := testServerWithStore(t)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("amount", "12.34")
_ = mw.WriteField("receipt_date", "2026-06-01")
_ = mw.WriteField("category_id", aCategoryID(t, s))
_ = mw.WriteField("tags", "Dental")
_ = mw.WriteField("tags", "tax-2026")
_ = mw.WriteField("tags", "dental") // case-dup of the first — must collapse
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
rw.Write(fakePNG())
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Tags:") {
t.Errorf("confirm page missing tags listing: %s", rec.Body.String())
}
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
}
got, err := s.store.ListReceiptTags(rows[0].ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != "Dental" || got[1] != "tax-2026" {
t.Errorf("receipt tags = %v, want [Dental tax-2026]", got)
}
}
func TestUpload_RejectsBadAttachment(t *testing.T) { func TestUpload_RejectsBadAttachment(t *testing.T) {
s := testServerWithStore(t) s := testServerWithStore(t)

View file

@ -84,6 +84,7 @@ type recentRow struct {
When string When string
Filename string Filename string
Attachments []attachLink Attachments []attachLink
Tags []string
} }
// attachLink is a viewable attachment reference shown under a recent row. // attachLink is a viewable attachment reference shown under a recent row.
@ -125,6 +126,11 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
for _, a := range atts { for _, a := range atts {
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename}) links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
} }
tags, err := s.store.ListReceiptTags(row.ID)
if err != nil {
s.serverError(w, "list receipt tags", err)
return
}
view.Rows = append(view.Rows, recentRow{ view.Rows = append(view.Rows, recentRow{
ID: row.ID, ID: row.ID,
Date: row.ReceiptDate.Format(dateLayout), Date: row.ReceiptDate.Format(dateLayout),
@ -134,6 +140,7 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
When: row.UploadedAt.Local().Format("2006-01-02"), When: row.UploadedAt.Local().Format("2006-01-02"),
Filename: row.OriginalFilename, Filename: row.OriginalFilename,
Attachments: links, Attachments: links,
Tags: tags,
}) })
} }
if hasMore { if hasMore {
@ -208,6 +215,7 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", rec.MimeType) w.Header().Set("Content-Type", rec.MimeType)
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename)) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData)) http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
} }
@ -221,6 +229,7 @@ func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", a.MimeType) w.Header().Set("Content-Type", a.MimeType)
w.Header().Set("X-Content-Type-Options", "nosniff") // don't let the browser sniff user bytes
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename)) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData)) http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
} }

View file

@ -19,6 +19,10 @@ import (
const ( const (
sessionCookie = "hsa_session" sessionCookie = "hsa_session"
loginCookie = "hsa_login" loginCookie = "hsa_login"
// sessionTTL bounds a session both as the cookie's MaxAge and as a server-side
// age check (see requireAuth), so a leaked cookie value can't be replayed forever.
sessionTTL = 12 * time.Hour
) )
// Server holds the wired dependencies for the HTTP handlers. // Server holds the wired dependencies for the HTTP handlers.
@ -75,6 +79,12 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts))) mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile))) mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile))) mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
mux.Handle("GET /ai", s.requireAuth(http.HandlerFunc(s.handleAI)))
mux.Handle("POST /ai/notes", s.requireAuth(http.HandlerFunc(s.handleAddNote)))
mux.Handle("POST /ai/notes/edit", s.requireAuth(http.HandlerFunc(s.handleEditNote)))
mux.Handle("POST /ai/notes/delete", s.requireAuth(http.HandlerFunc(s.handleDeleteNote)))
mux.Handle("GET /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReview)))
mux.Handle("POST /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReviewSubmit)))
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage))) mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory))) mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory))) mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
@ -218,7 +228,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
s.serverError(w, "encode session", err) s.serverError(w, "encode session", err)
return return
} }
s.setCookie(w, sessionCookie, encoded, 12*time.Hour) s.setCookie(w, sessionCookie, encoded, sessionTTL)
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
} }

View file

@ -5,6 +5,7 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"maisym.com/hsa/internal/auth" "maisym.com/hsa/internal/auth"
"maisym.com/hsa/internal/config" "maisym.com/hsa/internal/config"
@ -64,7 +65,7 @@ func TestHome_NoSession_RedirectsToLogin(t *testing.T) {
func TestHome_ValidSession_ShowsUploadForm(t *testing.T) { func TestHome_ValidSession_ShowsUploadForm(t *testing.T) {
s := testServerWithStore(t) s := testServerWithStore(t)
sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()}
cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey) cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)