2026-06-20 14:59:46 +00:00
|
|
|
|
# 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**.
|
2026-06-21 00:56:38 +00:00
|
|
|
|
- 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://`.
|
2026-06-20 14:59:46 +00:00
|
|
|
|
- `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.
|
2026-06-20 20:04:30 +00:00
|
|
|
|
- **people** — `id`, `label` (unique). Seeded from catalog; renamable via Manage. Format is "<FIRST NAME> <LAST NAME>"
|
2026-06-20 14:59:46 +00:00
|
|
|
|
- **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.
|
2026-06-20 20:04:30 +00:00
|
|
|
|
- **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.
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
`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).
|
2026-06-20 20:04:30 +00:00
|
|
|
|
- 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.
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
### 5.4 Duplicate warning
|
2026-06-20 20:04:30 +00:00
|
|
|
|
Whenever date and amount are both known (edit: those are mandatory fields aren't they?), the form calls `GET /duplicates` and warns
|
2026-06-20 14:59:46 +00:00
|
|
|
|
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
|
2026-06-21 00:56:38 +00:00
|
|
|
|
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).
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 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.
|
2026-06-20 20:04:30 +00:00
|
|
|
|
- Tags are **not** managed here (see §15 gaps).
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-20 20:04:30 +00:00
|
|
|
|
## 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
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
- `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.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-20 20:04:30 +00:00
|
|
|
|
## 12. Scheduled backups
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-20 20:04:30 +00:00
|
|
|
|
## 13. Durability & crash recovery
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-20 20:04:30 +00:00
|
|
|
|
## 14. Deployment & operations
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
- 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).
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-06-20 20:04:30 +00:00
|
|
|
|
## 15. Known gaps / not yet implemented
|
2026-06-20 14:59:46 +00:00
|
|
|
|
|
|
|
|
|
|
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.
|
2026-06-20 20:04:30 +00:00
|
|
|
|
|
|
|
|
|
|
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.
|