From 5c21f131fdb0587fadcb75c0696b6353291e2313 Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Thu, 18 Jun 2026 22:04:42 -0400 Subject: [PATCH] Add AI auto-fill, tally, recent views, and cheaper-by-default classification Features (see spec.md v2): - Wire receipt classification into the upload flow; cheap model (Haiku 4.5) is now the default, shown as a footnote with per-scan cost in cents. - Skip-AI toggle to enter fields by hand. - Duplicate-transaction warning: live check on date+amount, gated submit. - Tally tab: person x year totals with margins and grand total. - Recent uploads / recent receipts tabs with paging and file serving. - People reconcile on startup: merge stray partial names (e.g. "Jude" -> "Jude Tremblay"), reassigning receipts; idempotent seeding. - scripts/build.sh builds the binary; scripts/run.sh builds and runs with .env. Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 +- cmd/hsa/main.go | 7 + internal/classify/classify.go | 22 ++- internal/classify/pricing.go | 47 ++++++ internal/classify/pricing_test.go | 31 ++++ internal/config/config.go | 7 +- internal/storage/query.go | 263 +++++++++++++++++++++++++++++ internal/storage/query_test.go | 194 +++++++++++++++++++++ internal/web/scan.go | 8 + internal/web/static/style.css | 58 +++++++ internal/web/templates.go | 2 + internal/web/templates/recent.html | 30 ++++ internal/web/templates/tally.html | 41 +++++ internal/web/templates/upload.html | 124 +++++++++++++- internal/web/upload.go | 7 + internal/web/views.go | 203 ++++++++++++++++++++++ internal/web/views_test.go | 118 +++++++++++++ internal/web/web.go | 5 + scripts/build.sh | 3 +- scripts/run.sh | 18 ++ spec.md | 107 +++++++++++- 21 files changed, 1291 insertions(+), 9 deletions(-) create mode 100644 internal/classify/pricing.go create mode 100644 internal/classify/pricing_test.go create mode 100644 internal/storage/query.go create mode 100644 internal/storage/query_test.go create mode 100644 internal/web/templates/recent.html create mode 100644 internal/web/templates/tally.html create mode 100644 internal/web/views.go create mode 100644 internal/web/views_test.go create mode 100755 scripts/run.sh diff --git a/.env.example b/.env.example index f16ebe9..9ed93b0 100644 --- a/.env.example +++ b/.env.example @@ -38,4 +38,7 @@ CONFIG_PATH=./config.json # Tests never need this: they use a mock endpoint (or the cheapest model when # HSA_CLASSIFY_IT=1 is set explicitly). CLAUDE_API_KEY= -CLASSIFY_MODEL=claude-opus-4-8 +# Cheap model by default — every receipt upload makes one classification call, so +# keep this cheap unless you specifically need a stronger model. The active model +# is shown as a footnote on the upload page so you always know what a scan costs. +CLASSIFY_MODEL=claude-haiku-4-5-20251001 diff --git a/cmd/hsa/main.go b/cmd/hsa/main.go index de60ee7..17cc846 100644 --- a/cmd/hsa/main.go +++ b/cmd/hsa/main.go @@ -41,6 +41,13 @@ func main() { if err := store.Seed(catalog.CategoryNames(), catalog.PersonLabels()); err != nil { log.Fatalf("seed catalog: %v", err) } + // Collapse any stray partial-name people (e.g. "Jude" → "Jude Tremblay") left + // over from earlier data, reassigning their receipts to the canonical person. + if merged, err := store.ReconcilePeople(catalog.PersonLabels()); err != nil { + log.Fatalf("reconcile people: %v", err) + } else if merged > 0 { + log.Printf("reconciled %d stray person entr(ies) into canonical labels", merged) + } // Receipt auto-classification is best-effort: only enabled when an API key is set. var classifier *classify.Classifier diff --git a/internal/classify/classify.go b/internal/classify/classify.go index 478bec3..9fb00f4 100644 --- a/internal/classify/classify.go +++ b/internal/classify/classify.go @@ -35,6 +35,9 @@ type Suggestion struct { RawName string `json:"raw_name"` RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` + + // Usage is the token accounting for this call, used to compute its cost. + Usage Usage } // Classifier holds everything needed to classify a receipt. @@ -51,7 +54,7 @@ type Classifier struct { // catalog; pass an empty model to use a sensible default. func New(apiKey, model string, persons []config.Person, categories []config.Category) *Classifier { if model == "" { - model = "claude-opus-4-8" + model = config.DefaultClassifyModel } return &Classifier{ APIKey: apiKey, @@ -111,6 +114,12 @@ type apiResponse struct { Name string `json:"name"` Input json.RawMessage `json:"input"` } `json:"content"` + Usage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + } `json:"usage"` Error *struct { Type string `json:"type"` Message string `json:"message"` @@ -206,7 +215,16 @@ func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte if err := json.Unmarshal(blk.Input, &in); err != nil { return Suggestion{}, fmt.Errorf("parse tool input: %w", err) } - return c.normalize(in), nil + sug := c.normalize(in) + if ar.Usage != nil { + sug.Usage = Usage{ + InputTokens: ar.Usage.InputTokens, + OutputTokens: ar.Usage.OutputTokens, + CacheReadTokens: ar.Usage.CacheReadTokens, + CacheCreationTokens: ar.Usage.CacheCreationTokens, + } + } + return sug, nil } } return Suggestion{}, fmt.Errorf("no %s tool_use in response", toolName) diff --git a/internal/classify/pricing.go b/internal/classify/pricing.go new file mode 100644 index 0000000..c7a2410 --- /dev/null +++ b/internal/classify/pricing.go @@ -0,0 +1,47 @@ +package classify + +// Pricing for receipt classification. Token counts come straight from each API +// response (exact, free); per-token prices are not available from any Anthropic +// API, so they live here as constants keyed by exact model id. A model id is +// priced once and never re-priced — Anthropic ships price changes as new ids — so +// this table only needs a new row when we adopt a new model (i.e. when we'd be +// changing CLASSIFY_MODEL anyway). An unknown id yields ok=false, and callers show +// the token counts without a (wrong) dollar figure rather than guessing. + +// Usage is the token accounting from one Messages API response. +type Usage struct { + InputTokens int + OutputTokens int + CacheReadTokens int + CacheCreationTokens int +} + +// rate holds a model's price in cents per one million tokens. +type rate struct { + inPerM float64 // cents per 1M input tokens + outPerM float64 // cents per 1M output tokens +} + +// modelRates is the hand-maintained price table (cents per 1M tokens). +// $1/1M == 100 cents/1M. +var modelRates = map[string]rate{ + "claude-haiku-4-5-20251001": {inPerM: 100, outPerM: 500}, + "claude-haiku-4-5": {inPerM: 100, outPerM: 500}, + "claude-sonnet-4-6": {inPerM: 300, outPerM: 1500}, + "claude-opus-4-8": {inPerM: 500, outPerM: 2500}, + "claude-opus-4-7": {inPerM: 500, outPerM: 2500}, +} + +// CostCents returns the cost of a classification call in cents, and whether the +// model's price is known. Cache tokens are billed at the input rate (a slight +// over-estimate — cache reads actually bill at ~0.1× input — which is fine for a +// "what did this scan cost" readout). Returns ok=false for an unpriced model. +func CostCents(model string, u Usage) (float64, bool) { + r, ok := modelRates[model] + if !ok { + return 0, false + } + inTokens := float64(u.InputTokens + u.CacheReadTokens + u.CacheCreationTokens) + cents := inTokens*r.inPerM/1_000_000 + float64(u.OutputTokens)*r.outPerM/1_000_000 + return cents, true +} diff --git a/internal/classify/pricing_test.go b/internal/classify/pricing_test.go new file mode 100644 index 0000000..44e7e44 --- /dev/null +++ b/internal/classify/pricing_test.go @@ -0,0 +1,31 @@ +package classify + +import ( + "math" + "testing" +) + +func TestCostCents_Haiku(t *testing.T) { + // 1500 input + 100 output on Haiku ($1/1M in, $5/1M out). + // input: 1500 * 100c/1e6 = 0.15c ; output: 100 * 500c/1e6 = 0.05c → 0.20c + got, ok := CostCents("claude-haiku-4-5-20251001", Usage{InputTokens: 1500, OutputTokens: 100}) + if !ok { + t.Fatal("expected known model") + } + if math.Abs(got-0.20) > 1e-9 { + t.Errorf("cost = %v, want 0.20", got) + } +} + +func TestCostCents_CacheTokensBilledAtInputRate(t *testing.T) { + got, _ := CostCents("claude-haiku-4-5", Usage{CacheReadTokens: 1_000_000}) + if math.Abs(got-100) > 1e-9 { // 1M input-rate tokens = 100 cents + t.Errorf("cost = %v, want 100", got) + } +} + +func TestCostCents_UnknownModel(t *testing.T) { + if _, ok := CostCents("some-future-model", Usage{InputTokens: 1000}); ok { + t.Error("expected ok=false for unknown model") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 83d9ebe..05eb8bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,11 @@ import ( "strings" ) +// DefaultClassifyModel is the model used for receipt classification unless +// CLASSIFY_MODEL overrides it. Deliberately a cheap model so routine uploads +// don't burn API credits; override with a stronger model only when needed. +const DefaultClassifyModel = "claude-haiku-4-5-20251001" + // Config holds all runtime configuration, loaded from environment variables. type Config struct { IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com @@ -77,7 +82,7 @@ func Load() (Config, error) { // Domain config + classification (all optional; classification is best-effort). c.ConfigPath = envOr("CONFIG_PATH", "./config.json") c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY")) - c.ClassifyModel = envOr("CLASSIFY_MODEL", "claude-opus-4-8") + c.ClassifyModel = envOr("CLASSIFY_MODEL", DefaultClassifyModel) return c, nil } diff --git a/internal/storage/query.go b/internal/storage/query.go new file mode 100644 index 0000000..e4e3cbb --- /dev/null +++ b/internal/storage/query.go @@ -0,0 +1,263 @@ +package storage + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// ReceiptRow is a display row for listings and duplicate warnings. It carries the +// resolved category/person labels (not ids) and omits the image blob. +type ReceiptRow struct { + ID string + UploadedBy string + UploadedAt time.Time + ReceiptDate time.Time + AmountCents int64 + Category string + Who string // "" when unassigned + OriginalFilename string +} + +// recentColumns whitelists the sortable columns for ListRecent, so the column +// name can be interpolated into SQL safely (it is never user free-text). +var recentColumns = map[string]string{ + "uploaded_at": "r.uploaded_at", + "receipt_date": "r.receipt_date", +} + +// ListRecent returns up to limit non-deleted receipts ordered by the given column +// (descending), skipping offset rows. orderBy must be "uploaded_at" or +// "receipt_date". One extra row is fetched so callers can tell if more exist. +func (s *Store) ListRecent(orderBy string, limit, offset int) (rows []ReceiptRow, hasMore bool, err error) { + col, ok := recentColumns[orderBy] + if !ok { + return nil, false, fmt.Errorf("invalid sort column %q", orderBy) + } + q := `SELECT r.id, r.uploaded_by, r.uploaded_at, r.receipt_date, r.amount_cents, + c.label, COALESCE(p.label, ''), r.original_filename + FROM receipts r + JOIN categories c ON c.id = r.category_id + LEFT JOIN people p ON p.id = r.person_id + WHERE r.deleted_at IS NULL + ORDER BY ` + col + ` DESC, r.id DESC + LIMIT ? OFFSET ?` + // Fetch one extra to detect "has more". + res, err := s.db.Query(q, limit+1, offset) + if err != nil { + return nil, false, fmt.Errorf("list recent: %w", err) + } + defer res.Close() + + for res.Next() { + row, err := scanReceiptRow(res) + if err != nil { + return nil, false, err + } + rows = append(rows, row) + } + if err := res.Err(); err != nil { + return nil, false, err + } + if len(rows) > limit { + return rows[:limit], true, nil + } + return rows, false, nil +} + +// FindDuplicates returns non-deleted receipts with the same receipt date and +// amount — likely the same transaction already posted. Empty means no match. +func (s *Store) FindDuplicates(receiptDate time.Time, amountCents int64) ([]ReceiptRow, error) { + q := `SELECT r.id, r.uploaded_by, r.uploaded_at, r.receipt_date, r.amount_cents, + c.label, COALESCE(p.label, ''), r.original_filename + FROM receipts r + JOIN categories c ON c.id = r.category_id + LEFT JOIN people p ON p.id = r.person_id + WHERE r.deleted_at IS NULL AND r.receipt_date = ? AND r.amount_cents = ? + ORDER BY r.uploaded_at DESC` + res, err := s.db.Query(q, receiptDate.UTC().Format(rfc3339), amountCents) + if err != nil { + return nil, fmt.Errorf("find duplicates: %w", err) + } + defer res.Close() + + var rows []ReceiptRow + for res.Next() { + row, err := scanReceiptRow(res) + if err != nil { + return nil, err + } + rows = append(rows, row) + } + return rows, res.Err() +} + +// rowScanner is satisfied by *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +} + +func scanReceiptRow(sc rowScanner) (ReceiptRow, error) { + var row ReceiptRow + var uploadedAt, receiptDate string + if err := sc.Scan(&row.ID, &row.UploadedBy, &uploadedAt, &receiptDate, + &row.AmountCents, &row.Category, &row.Who, &row.OriginalFilename); err != nil { + return ReceiptRow{}, fmt.Errorf("scan receipt row: %w", err) + } + row.UploadedAt, _ = time.Parse(rfc3339, uploadedAt) + row.ReceiptDate, _ = time.Parse(rfc3339, receiptDate) + return row, nil +} + +// TallyRow is one person's totals across years (cents). +type TallyRow struct { + Person string + ByYear map[int]int64 + Total int64 +} + +// Tally is the person × year totals matrix with margins, for the Tally tab. +type Tally struct { + Years []int // sorted ascending, only years that have data + Rows []TallyRow // one per person ("Unassigned" sorts last) + YearTotals map[int]int64 // grand total per year (all people) + Grand int64 // overall grand total +} + +// Tally sums non-deleted receipt amounts grouped by person and by the year of the +// receipt date. Receipts with no "who" are grouped under "Unassigned". +func (s *Store) Tally() (Tally, error) { + q := `SELECT COALESCE(p.label, 'Unassigned'), + CAST(strftime('%Y', r.receipt_date) AS INTEGER), + SUM(r.amount_cents) + FROM receipts r + LEFT JOIN people p ON p.id = r.person_id + WHERE r.deleted_at IS NULL + GROUP BY COALESCE(p.label, 'Unassigned'), strftime('%Y', r.receipt_date)` + res, err := s.db.Query(q) + if err != nil { + return Tally{}, fmt.Errorf("tally: %w", err) + } + defer res.Close() + + byPerson := map[string]map[int]int64{} + yearSet := map[int]bool{} + t := Tally{YearTotals: map[int]int64{}} + for res.Next() { + var person string + var year int + var sum int64 + if err := res.Scan(&person, &year, &sum); err != nil { + return Tally{}, fmt.Errorf("scan tally: %w", err) + } + if byPerson[person] == nil { + byPerson[person] = map[int]int64{} + } + byPerson[person][year] += sum + yearSet[year] = true + t.YearTotals[year] += sum + t.Grand += sum + } + if err := res.Err(); err != nil { + return Tally{}, err + } + + for y := range yearSet { + t.Years = append(t.Years, y) + } + sort.Ints(t.Years) + + names := make([]string, 0, len(byPerson)) + for name := range byPerson { + names = append(names, name) + } + // Alphabetical, but "Unassigned" always last. + sort.Slice(names, func(i, j int) bool { + if (names[i] == "Unassigned") != (names[j] == "Unassigned") { + return names[j] == "Unassigned" + } + return strings.ToLower(names[i]) < strings.ToLower(names[j]) + }) + for _, name := range names { + row := TallyRow{Person: name, ByYear: byPerson[name]} + for _, v := range byPerson[name] { + row.Total += v + } + t.Rows = append(t.Rows, row) + } + return t, nil +} + +// ReconcilePeople merges stray partial-name person rows into the canonical +// config-seeded labels: e.g. a leftover "Jude" is merged into "Jude Tremblay". +// For each existing person that is NOT itself a canonical label, if its label +// matches the first name of exactly one canonical label (case-insensitive), its +// receipts are reassigned to that canonical person and the stray row is deleted. +// Ambiguous or unmatched strays are left untouched. Returns the number merged. +// +// This is conservative on purpose — it only collapses an unambiguous first-name +// duplicate, so genuinely custom people added via the manage page survive. +func (s *Store) ReconcilePeople(canonical []string) (int, error) { + // Map first-name -> canonical label, dropping ambiguous first names. + firstToCanon := map[string]string{} + ambiguous := map[string]bool{} + canonSet := map[string]bool{} + for _, label := range canonical { + label = strings.TrimSpace(label) + if label == "" { + continue + } + canonSet[strings.ToLower(label)] = true + first := strings.ToLower(strings.Fields(label)[0]) + if _, seen := firstToCanon[first]; seen { + ambiguous[first] = true + } + firstToCanon[first] = label + } + + people, err := s.ListPeople() + if err != nil { + return 0, err + } + + merged := 0 + for _, p := range people { + lower := strings.ToLower(strings.TrimSpace(p.Label)) + if canonSet[lower] { + continue // already a canonical full name + } + canon, ok := firstToCanon[lower] + if !ok || ambiguous[lower] { + continue // no unambiguous canonical match — leave it alone + } + if err := s.mergePerson(p.ID, canon); err != nil { + return merged, err + } + merged++ + } + return merged, nil +} + +// mergePerson reassigns every receipt pointing at stray personID to the canonical +// person (looked up / created by label), then deletes the stray row. Done in a +// transaction so a receipt never loses its "who". +func (s *Store) mergePerson(strayID int64, canonLabel string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin merge: %w", err) + } + defer tx.Rollback() + + var canonID int64 + if err := tx.QueryRow(`SELECT id FROM people WHERE label = ?`, canonLabel).Scan(&canonID); err != nil { + return fmt.Errorf("find canonical %q: %w", canonLabel, err) + } + if _, err := tx.Exec(`UPDATE receipts SET person_id = ? WHERE person_id = ?`, canonID, strayID); err != nil { + return fmt.Errorf("reassign receipts: %w", err) + } + if _, err := tx.Exec(`DELETE FROM people WHERE id = ?`, strayID); err != nil { + return fmt.Errorf("delete stray person: %w", err) + } + return tx.Commit() +} diff --git a/internal/storage/query_test.go b/internal/storage/query_test.go new file mode 100644 index 0000000..b1382d9 --- /dev/null +++ b/internal/storage/query_test.go @@ -0,0 +1,194 @@ +package storage + +import ( + "testing" + "time" +) + +// insertOn inserts a receipt with the given id, person, category, amount and +// receipt date (uploaded_at defaults to now). +func insertOn(t *testing.T, s *Store, id string, categoryID int64, personID *int64, amountCents int64, date time.Time) { + t.Helper() + r := sampleReceipt(categoryID) + r.ID = id + r.PersonID = personID + r.AmountCents = amountCents + r.ReceiptDate = date + if err := s.Insert(r); err != nil { + t.Fatalf("Insert %s: %v", id, err) + } +} + +func TestFindDuplicates_MatchesDateAndAmount(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + insertOn(t, s, "a", cid, nil, 4250, d) + insertOn(t, s, "b", cid, nil, 4250, d.AddDate(0, 0, 1)) // different date + insertOn(t, s, "c", cid, nil, 9999, d) // different amount + + matches, err := s.FindDuplicates(d, 4250) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].ID != "a" { + t.Fatalf("matches = %+v, want only id a", matches) + } +} + +func TestFindDuplicates_ExcludesSoftDeleted(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + insertOn(t, s, "a", cid, nil, 4250, d) + if err := s.SoftDelete("a"); err != nil { + t.Fatal(err) + } + matches, err := s.FindDuplicates(d, 4250) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("matches = %+v, want none (soft-deleted)", matches) + } +} + +func TestListRecent_OrderAndPaging(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + // Insert 12 receipts with ascending receipt dates id00..id11. + for i := 0; i < 12; i++ { + insertOn(t, s, padID(i), cid, nil, int64(100+i), base.AddDate(0, 0, i)) + } + + rows, hasMore, err := s.ListRecent("receipt_date", 10, 0) + if err != nil { + t.Fatal(err) + } + if len(rows) != 10 || !hasMore { + t.Fatalf("page1 len=%d hasMore=%v, want 10,true", len(rows), hasMore) + } + // Newest receipt date first → id11. + if rows[0].ID != padID(11) { + t.Errorf("first row = %s, want %s", rows[0].ID, padID(11)) + } + + rows2, hasMore2, err := s.ListRecent("receipt_date", 10, 10) + if err != nil { + t.Fatal(err) + } + if len(rows2) != 2 || hasMore2 { + t.Fatalf("page2 len=%d hasMore=%v, want 2,false", len(rows2), hasMore2) + } +} + +func TestListRecent_RejectsBadColumn(t *testing.T) { + s := newTestStore(t) + if _, _, err := s.ListRecent("amount_cents; DROP TABLE receipts", 10, 0); err == nil { + t.Error("expected error for non-whitelisted column") + } +} + +func TestTally_MatrixAndMargins(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + alice, _ := s.AddPerson("Alice") + bob, _ := s.AddPerson("Bob") + y2025 := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + y2026 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + insertOn(t, s, "1", cid, &alice, 1000, y2025) + insertOn(t, s, "2", cid, &alice, 2000, y2026) + insertOn(t, s, "3", cid, &bob, 500, y2026) + insertOn(t, s, "4", cid, nil, 700, y2026) // unassigned + + tal, err := s.Tally() + if err != nil { + t.Fatal(err) + } + if len(tal.Years) != 2 || tal.Years[0] != 2025 || tal.Years[1] != 2026 { + t.Fatalf("years = %v, want [2025 2026]", tal.Years) + } + if tal.Grand != 4200 { + t.Errorf("grand = %d, want 4200", tal.Grand) + } + if tal.YearTotals[2026] != 3200 { + t.Errorf("2026 total = %d, want 3200", tal.YearTotals[2026]) + } + // Unassigned must sort last. + last := tal.Rows[len(tal.Rows)-1] + if last.Person != "Unassigned" || last.Total != 700 { + t.Errorf("last row = %+v, want Unassigned/700", last) + } + // Alice's per-year cells. + var aliceRow TallyRow + for _, r := range tal.Rows { + if r.Person == "Alice" { + aliceRow = r + } + } + if aliceRow.ByYear[2025] != 1000 || aliceRow.ByYear[2026] != 2000 || aliceRow.Total != 3000 { + t.Errorf("alice row = %+v", aliceRow) + } +} + +func TestReconcilePeople_MergesStrayFirstName(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + // Canonical seeded person + a stray first-name-only duplicate. + if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil { + t.Fatal(err) + } + stray, err := s.AddPerson("Jude") + if err != nil { + t.Fatal(err) + } + insertOn(t, s, "r1", cid, &stray, 1000, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + + merged, err := s.ReconcilePeople([]string{"Jude Tremblay"}) + if err != nil { + t.Fatal(err) + } + if merged != 1 { + t.Fatalf("merged = %d, want 1", merged) + } + + // Stray gone; only the canonical person remains. + people, _ := s.ListPeople() + if len(people) != 1 || people[0].Label != "Jude Tremblay" { + t.Fatalf("people = %+v, want only Jude Tremblay", people) + } + // The receipt was reassigned to the canonical person (not orphaned). + got, _ := s.Get("r1") + if got.PersonID == nil || *got.PersonID != people[0].ID { + t.Errorf("receipt person = %v, want %d", got.PersonID, people[0].ID) + } +} + +func TestReconcilePeople_LeavesCustomAndCanonicalAlone(t *testing.T) { + s := newTestStore(t) + if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil { + t.Fatal(err) + } + if _, err := s.AddPerson("Grandma"); err != nil { // no canonical first-name match + t.Fatal(err) + } + merged, err := s.ReconcilePeople([]string{"Jude Tremblay"}) + if err != nil { + t.Fatal(err) + } + if merged != 0 { + t.Fatalf("merged = %d, want 0", merged) + } + people, _ := s.ListPeople() + if len(people) != 2 { + t.Errorf("people = %+v, want 2 (canonical + custom kept)", people) + } +} + +// padID returns a deterministic 36-char UUID-ish id from an index. +func padID(i int) string { + const base = "00000000-0000-0000-0000-0000000000" + return base + string(rune('0'+i/10)) + string(rune('0'+i%10)) +} diff --git a/internal/web/scan.go b/internal/web/scan.go index d54f50c..b48cb8f 100644 --- a/internal/web/scan.go +++ b/internal/web/scan.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "maisym.com/hsa/internal/classify" "maisym.com/hsa/internal/storage" ) @@ -25,6 +26,9 @@ type classifyResponse struct { RawName string `json:"raw_name"` RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` + + Model string `json:"model"` // model that ran, for display + CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown } // handleClassify accepts an uploaded receipt image and returns suggested form @@ -78,6 +82,10 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { RawName: sug.RawName, RawDate: sug.RawDate, RawAmount: sug.RawAmount, + Model: s.classifier.Model, + } + if cents, ok := classify.CostCents(s.classifier.Model, sug.Usage); ok { + out.CostCents = ¢s } if id, ok := idForLabel(sug.Category, cats); ok { out.CategoryID = &id diff --git a/internal/web/static/style.css b/internal/web/static/style.css index d4b682a..060d4a1 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -32,6 +32,64 @@ input, select { a { color: #1a73e8; } small { color: #555; } +/* Footnote under the file picker: which model auto-fills the form. */ +.footnote { + font-size: .8rem; + color: #555; + margin: .4rem 0 0; +} +.footnote code { + background: #eee; + padding: .05rem .3rem; + border-radius: .25rem; +} +.footnote .err-inline { color: #a50e0e; } + +/* Skip-AI / "add anyway" inline checkboxes. */ +label.check { + display: flex; + align-items: center; + gap: .4rem; + font-weight: 400; + font-size: .9rem; + margin: .5rem 0 0; +} +label.check input { width: auto; } + +/* Possible-duplicate warning panel. */ +.dup-warn { + background: #fef7e0; + border: 1px solid #f0c000; + color: #7a5a00; + padding: .6rem; + border-radius: .4rem; + margin: 1rem 0; + font-size: .9rem; +} +.dup-warn ul { margin: .4rem 0; padding-left: 1.1rem; } +.dup-warn label.check { color: #7a5a00; font-weight: 600; } +button.primary:disabled { background: #9aa0a6; } + +/* Tally matrix. */ +.tally-wrap { overflow-x: auto; } +table.tally { border-collapse: collapse; width: 100%; font-size: .95rem; } +table.tally th, table.tally td { padding: .4rem .6rem; border-bottom: 1px solid #eee; } +table.tally .num { text-align: right; font-variant-numeric: tabular-nums; } +table.tally thead th { border-bottom: 2px solid #ccc; } +table.tally tbody th { text-align: left; font-weight: 600; } +table.tally .total { font-weight: 600; } +table.tally tfoot { border-top: 2px solid #ccc; } +table.tally tfoot th, table.tally tfoot td { font-weight: 700; } +table.tally .grand { color: #137333; } +table.tally .muted { color: #bbb; } + +/* Recent lists + tabs + pager. */ +p.tabs a.active { font-weight: 700; text-decoration: none; color: #137333; } +ul.recent { list-style: none; padding: 0; } +ul.recent li { padding: .5rem 0; border-bottom: 1px solid #eee; } +ul.recent small { color: #555; } +p.pager { margin-top: 1rem; } + .err { background: #fce8e6; color: #a50e0e; diff --git a/internal/web/templates.go b/internal/web/templates.go index 8a3e51f..a79dcef 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -22,4 +22,6 @@ var ( confirmPage = parsePage("confirm.html") managePage = parsePage("manage.html") exportPage = parsePage("export.html") + tallyPage = parsePage("tally.html") + recentPage = parsePage("recent.html") ) diff --git a/internal/web/templates/recent.html b/internal/web/templates/recent.html new file mode 100644 index 0000000..64e9082 --- /dev/null +++ b/internal/web/templates/recent.html @@ -0,0 +1,30 @@ +{{define "title"}}{{.Title}}{{end}} +{{define "content"}} +
+ ← Add receipt + Tally · Manage · Export +
+

{{.Title}}

+

+ By upload date · + By receipt date +

+{{if not .Rows}} +

No receipts to show.

+{{else}} + +

+ {{if .PrevURL}}← Newer{{end}} + {{if and .PrevURL .NextURL}} · {{end}} + {{if .NextURL}}Load next 10 →{{end}} +

+{{end}} +{{end}} diff --git a/internal/web/templates/tally.html b/internal/web/templates/tally.html new file mode 100644 index 0000000..5489075 --- /dev/null +++ b/internal/web/templates/tally.html @@ -0,0 +1,41 @@ +{{define "title"}}Tally{{end}} +{{define "content"}} +
+ ← Add receipt + Recent · Manage · Export +
+

Tally

+{{if .Empty}} +

No receipts yet. Totals will appear here once you add some.

+{{else}} +

Totals by person and receipt-date year, in dollars. Right column is each +person's total; bottom row is each year's total.

+
+ + + + + {{range .Years}}{{end}} + + + + + {{range .Rows}} + + + {{range .Cells}}{{end}} + + + {{end}} + + + + + {{range .YearTotals}}{{end}} + + + +
Who{{.}}Total
{{.Person}}{{if .}}{{.}}{{else}}{{end}}{{.Total}}
Total{{.}}{{.Grand}}
+
+{{end}} +{{end}} diff --git a/internal/web/templates/upload.html b/internal/web/templates/upload.html index a5630bb..5ac742e 100644 --- a/internal/web/templates/upload.html +++ b/internal/web/templates/upload.html @@ -2,13 +2,19 @@ {{define "content"}}
{{.Subject}} - Manage · Export · Log out + Tally · Recent · Manage · Export · Log out

Add receipt

{{range .Errors}}
{{.}}
{{end}} -
+ + {{if .ClassifyEnabled}} + +

Receipt is read automatically by {{.ClassifyModel}} to pre-fill the fields below — review before submitting.

+ {{else}} +

Auto-fill is off (no API key configured). Enter the fields manually.

+ {{end}} @@ -24,6 +30,118 @@ {{range .People}}{{end}} - + +
+ + + +{{if .ClassifyEnabled}} + +{{end}} {{end}} diff --git a/internal/web/upload.go b/internal/web/upload.go index 16f9ef1..3b551ba 100644 --- a/internal/web/upload.go +++ b/internal/web/upload.go @@ -26,6 +26,11 @@ type formView struct { CategoryID string PersonID string Errors []string + + // ClassifyModel is the model that will read the receipt to pre-fill the form, + // shown as a footnote. ClassifyEnabled is false when no API key is configured. + ClassifyModel string + ClassifyEnabled bool } func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) { @@ -56,6 +61,8 @@ func (s *Server) lookups() (cats, people []storage.Lookup, err error) { } func (s *Server) renderForm(w http.ResponseWriter, v formView) { + v.ClassifyModel = s.cfg.ClassifyModel + v.ClassifyEnabled = s.classifier != nil w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil { s.serverError(w, "render form", err) diff --git a/internal/web/views.go b/internal/web/views.go new file mode 100644 index 0000000..44ed3a2 --- /dev/null +++ b/internal/web/views.go @@ -0,0 +1,203 @@ +package web + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "maisym.com/hsa/internal/receipt" +) + +const recentPageSize = 10 + +// dupMatch is one possible-duplicate receipt returned to the upload page. +type dupMatch struct { + Date string `json:"date"` + Amount string `json:"amount"` + Category string `json:"category"` + Who string `json:"who"` + By string `json:"by"` + Uploaded string `json:"uploaded"` + Filename string `json:"filename"` +} + +// handleDuplicates returns receipts already posted with the same date+amount, so +// the upload page can warn before the user submits. Read-only; mutates nothing. +func (s *Server) handleDuplicates(w http.ResponseWriter, r *http.Request) { + dateStr := r.URL.Query().Get("date") + amountStr := r.URL.Query().Get("amount") + + date, err := time.Parse(dateLayout, dateStr) + if err != nil { + http.Error(w, "bad date", http.StatusBadRequest) + return + } + amountCents, err := receipt.ParseAmountCents(amountStr) + if err != nil { + http.Error(w, "bad amount", http.StatusBadRequest) + return + } + + matches, err := s.store.FindDuplicates(date, amountCents) + if err != nil { + s.serverError(w, "find duplicates", err) + return + } + + out := make([]dupMatch, 0, len(matches)) + for _, m := range matches { + out = append(out, dupMatch{ + Date: m.ReceiptDate.Format(dateLayout), + Amount: dollars(m.AmountCents), + Category: m.Category, + Who: m.Who, + By: m.UploadedBy, + Uploaded: m.UploadedAt.Local().Format("2006-01-02 15:04"), + Filename: m.OriginalFilename, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"matches": out}) +} + +// recentView is the data for the recent-uploads / recent-receipts tab. +type recentView struct { + Title string // page heading + ByLabel string // "upload date" or "receipt date" + Rows []recentRow // the receipts on this page + PrevURL string // "" when on the first page + NextURL string // "" when there are no more rows + BasePath string // /recent or /recent/receipts +} + +// recentRow is one listing row with display-formatted fields. +type recentRow struct { + ID string + Date string + Amount string + Category string + Who string + When string + Filename string +} + +func (s *Server) handleRecentUploads(w http.ResponseWriter, r *http.Request) { + s.renderRecent(w, r, "uploaded_at", "Recent uploads", "upload date", "/recent") +} + +func (s *Server) handleRecentReceipts(w http.ResponseWriter, r *http.Request) { + s.renderRecent(w, r, "receipt_date", "Recent receipts", "receipt date", "/recent/receipts") +} + +func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, title, byLabel, basePath string) { + offset := 0 + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + offset = n + } + } + + rows, hasMore, err := s.store.ListRecent(orderBy, recentPageSize, offset) + if err != nil { + s.serverError(w, "list recent", err) + return + } + + view := recentView{Title: title, ByLabel: byLabel, BasePath: basePath} + for _, row := range rows { + view.Rows = append(view.Rows, recentRow{ + ID: row.ID, + Date: row.ReceiptDate.Format(dateLayout), + Amount: dollars(row.AmountCents), + Category: row.Category, + Who: row.Who, + When: row.UploadedAt.Local().Format("2006-01-02 15:04"), + Filename: row.OriginalFilename, + }) + } + if hasMore { + view.NextURL = fmt.Sprintf("%s?offset=%d", basePath, offset+recentPageSize) + } + if offset > 0 { + prev := offset - recentPageSize + if prev <= 0 { + view.PrevURL = basePath + } else { + view.PrevURL = fmt.Sprintf("%s?offset=%d", basePath, prev) + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := recentPage.ExecuteTemplate(w, "base", view); err != nil { + s.serverError(w, "render recent", err) + } +} + +// tallyView is the rendered person × year matrix. +type tallyView struct { + Years []int + Rows []tallyRowView + YearTotals []string // aligned with Years + Grand string + Empty bool +} + +type tallyRowView struct { + Person string + Cells []string // aligned with Years (dollars, "" for no data) + Total string +} + +func (s *Server) handleTally(w http.ResponseWriter, r *http.Request) { + t, err := s.store.Tally() + if err != nil { + s.serverError(w, "tally", err) + return + } + + view := tallyView{Years: t.Years, Empty: len(t.Rows) == 0, Grand: dollars(t.Grand)} + for _, y := range t.Years { + view.YearTotals = append(view.YearTotals, dollars(t.YearTotals[y])) + } + for _, row := range t.Rows { + rv := tallyRowView{Person: row.Person, Total: dollars(row.Total)} + for _, y := range t.Years { + if v, ok := row.ByYear[y]; ok { + rv.Cells = append(rv.Cells, dollars(v)) + } else { + rv.Cells = append(rv.Cells, "") + } + } + view.Rows = append(view.Rows, rv) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tallyPage.ExecuteTemplate(w, "base", view); err != nil { + s.serverError(w, "render tally", err) + } +} + +// handleReceiptFile serves a stored receipt's original file (from the DB blob, so +// it works even if the on-disk copy is missing). Used by the recent-list links. +func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + rec, err := s.store.Get(id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", rec.MimeType) + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename)) + http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData)) +} + +// dollars formats integer cents as a plain dollar string, e.g. 1234 -> "12.34". +func dollars(cents int64) string { + if cents < 0 { + return "-" + dollars(-cents) + } + return fmt.Sprintf("%d.%02d", cents/100, cents%100) +} diff --git a/internal/web/views_test.go b/internal/web/views_test.go new file mode 100644 index 0000000..767ed44 --- /dev/null +++ b/internal/web/views_test.go @@ -0,0 +1,118 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "maisym.com/hsa/internal/receipt" +) + +// addReceipt inserts a receipt directly via the store for view tests. +func addReceipt(t *testing.T, s *Server, id string, amountCents int64, date time.Time) { + t.Helper() + cats, err := s.store.ListCategories() + if err != nil || len(cats) == 0 { + t.Fatalf("ListCategories: %v", err) + } + r := receipt.Receipt{ + ID: id, + UploadedBy: "jm@example.com", + UploadedAt: time.Now().UTC(), + ReceiptDate: date, + AmountCents: amountCents, + CategoryID: cats[0].ID, + FilePath: id + ".png", + ImageData: []byte("\x89PNG\r\n\x1a\nfake"), + FileSizeBytes: 12, + OriginalFilename: id + ".png", + MimeType: "image/png", + } + if err := s.store.Insert(r); err != nil { + t.Fatalf("Insert: %v", err) + } +} + +func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + return rec +} + +func TestDuplicates_JSONMatch(t *testing.T) { + s := testServerWithStore(t) + addReceipt(t, s, "dup1", 4250, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + + rec := get(t, s, "/duplicates?date=2026-06-01&amount=42.50") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "42.50") || !strings.Contains(body, "dup1.png") { + t.Errorf("body missing match: %s", body) + } +} + +func TestDuplicates_NoMatchEmpty(t *testing.T) { + s := testServerWithStore(t) + addReceipt(t, s, "x", 100, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + rec := get(t, s, "/duplicates?date=2026-06-01&amount=99.99") + if !strings.Contains(rec.Body.String(), `"matches":[]`) { + t.Errorf("expected empty matches, got %s", rec.Body.String()) + } +} + +func TestTallyPage_RendersTotals(t *testing.T) { + s := testServerWithStore(t) + addReceipt(t, s, "t1", 1000, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + addReceipt(t, s, "t2", 2500, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)) + + rec := get(t, s, "/tally") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "2026") || !strings.Contains(body, "35.00") { + t.Errorf("tally body missing year/grand: %s", body) + } +} + +func TestRecentPage_ListsAndPages(t *testing.T) { + s := testServerWithStore(t) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < 12; i++ { + addReceipt(t, s, "rec"+strconv.Itoa(i), int64(100+i), base.AddDate(0, 0, i)) + } + rec := get(t, s, "/recent") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Load next 10") { + t.Errorf("expected pager on first page: %s", body) + } + if strings.Count(body, "/receipt/rec") != 10 { + t.Errorf("expected 10 rows, got %d", strings.Count(body, "/receipt/rec")) + } +} + +func TestReceiptFile_ServesBlob(t *testing.T) { + s := testServerWithStore(t) + addReceipt(t, s, "file1", 100, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + rec := get(t, s, "/receipt/file1/file") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/png" { + t.Errorf("content-type = %q, want image/png", ct) + } + if !strings.HasPrefix(rec.Body.String(), "\x89PNG") { + t.Errorf("body not the stored blob") + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 88f1bf3..c144d9b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -69,6 +69,11 @@ func (s *Server) Routes() http.Handler { mux.Handle("GET /{$}", s.requireAuth(http.HandlerFunc(s.handleUploadForm))) mux.Handle("POST /upload", s.requireAuth(http.HandlerFunc(s.handleUpload))) mux.Handle("POST /classify", s.requireAuth(http.HandlerFunc(s.handleClassify))) + mux.Handle("GET /duplicates", s.requireAuth(http.HandlerFunc(s.handleDuplicates))) + mux.Handle("GET /tally", s.requireAuth(http.HandlerFunc(s.handleTally))) + mux.Handle("GET /recent", s.requireAuth(http.HandlerFunc(s.handleRecentUploads))) + 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 /manage", s.requireAuth(http.HandlerFunc(s.handleManage))) mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory))) mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory))) diff --git a/scripts/build.sh b/scripts/build.sh index 1eab91e..2f3c0a2 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,4 +2,5 @@ set -euo pipefail cd "$(dirname "$0")/.." source scripts/go-env.sh -go build ./... +go build -o hsa ./cmd/hsa +echo "Built ./hsa" diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..a8ad37c --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Build and run the app locally, loading config from .env. +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/go-env.sh + +if [[ ! -f .env ]]; then + echo "No .env found. Copy .env.example to .env and fill it in." >&2 + exit 1 +fi + +# Load .env into the environment (export every assignment). +set -a +source .env +set +a + +go build -o hsa ./cmd/hsa +exec ./hsa "$@" diff --git a/spec.md b/spec.md index 13240a0..5d289d9 100644 --- a/spec.md +++ b/spec.md @@ -106,4 +106,109 @@ CSV / tax-software export Reimbursement tracking (paid vs pending status) In-place editing of existing receipts Multi-tenancy or per-user data isolation -Notification / reminders \ No newline at end of file +Notification / reminders + +================================================================================ +v2 — additions +================================================================================ +These supersede the v1 "out of scope" entries for OCR-assisted entry (now present +via the classifier) and "Reports, totals, dashboards" (see Tally below). Same two +users, same auth model, same storage. Mobile-first still applies. + +1. Skip-AI toggle on upload + +A control on the upload form lets the user opt out of AI parsing for the current +receipt — for receipts they know are too hard to read, or to avoid spending an API +call on a bad result. + - Default: AI parsing ON (auto-fill runs when a file is attached). + - When "skip AI" is selected, NO /classify call is made; the user fills amount, + date, category, and who by hand. + - The model footnote stays visible but reads as disabled when skip is selected, + so the user always knows whether a call will happen and which model it uses. + - Skipping is per-upload, not a saved preference. + +2. Duplicate-transaction warning before insert + +Once a receipt has both a date and a dollar amount (whether typed or AI-filled), +check the DB for an already-posted receipt that looks like the same transaction, +and make the user confirm before inserting a possible duplicate. + - Match: same receipt_date AND same amount_cents, among non-soft-deleted rows. + When a "who" is set on both, prefer/highlight a same-person match; a match with + a different or absent person is still shown as a weaker warning. + - On a match, show the existing receipt's details: date, amount, category, who, + uploaded_by, uploaded_at, original_filename (and a link to view it). + - User chooses: Cancel (abort — nothing inserted) or Approve (insert anyway, as a + deliberate duplicate). No new schema; this is a pre-insert read + confirm step. + - No match → insert proceeds as today with no extra prompt. + +3. Tally tab + +A totals view (read-only). Bucketed by the YEAR of receipt_date. + - Matrix: one row per person, one column per year that has data; each cell is the + summed amount for that person in that year. + - Include an "Unassigned" row for receipts with no "who". + - Right margin column: grand total per year (all persons). + - Bottom margin row: grand total per person across all years. + - Bottom-right cell: overall grand total tracked. + - Excludes soft-deleted rows. Amounts shown in dollars (cents / 100). + +4. Recent uploads tab (by upload date) + +A list of the most recently ADDED receipts, ordered by uploaded_at descending. + - Show the 10 most recent, with a "Load next 10" control that pages further back + (offset or cursor based). + - Each row: receipt_date, amount, category, who, original_filename, and a link to + view/download. Excludes soft-deleted rows. + +5. Recent receipts tab (by receipt date) + +Identical to #4 but ordered by receipt_date descending instead of uploaded_at — +"newest receipts" rather than "newest uploads". Same 10 + "Load next 10" paging. + +6. People catalog integrity (bug) + +The Manage page currently shows partial-name duplicates (e.g. both "Jude" and +"Jude Tremblay"). Only the canonical full names seeded from config.json +("First Last", per Person.Label) should exist as people. + - Remove stray partial entries; keep only the config-seeded canonical labels. + - Before deleting a partial entry, reassign any receipts that point at it to the + matching canonical person so no receipt loses its "who". + - Seeding must be idempotent: re-seeding from config.json must not create a second + row for a person who already exists under the canonical label. + +7. Per-parse cost shown in cents (¢) + +After a receipt is classified, show the cost of that single AI call, in cents, +using the cent sign (e.g. "0.3¢"). The cost is computed locally from the API +response — no extra API call needed. + - The Messages API response includes a usage object (input_tokens, + output_tokens, and cache_creation/cache_read token counts). The classifier + should capture these and return them alongside the suggestion. + - Cost = input_tokens × input_price + output_tokens × output_price, using the + active model's per-token rates. For the default model (Haiku 4.5): $1 per 1M + input tokens and $5 per 1M output tokens — i.e. $0.000001/input-token and + $0.000005/output-token. Cache-read tokens bill at ~0.1× input; treat them at + the input rate unless we add exact cache pricing later. + - Display in cents with the ¢ sign next to where the model footnote shows the + model name, so the user sees both which model ran and what the scan cost. + +Where the rates come from (this is the only maintenance cost of the feature): + - Token counts are exact and free — they come straight from the response's + usage object, no estimation. + - Per-token PRICES are not available from any API (the Models API exposes + capabilities, not dollars), so they live as hardcoded constants in a small + rate table keyed by exact model id: + haiku-4-5 → $1/1M in, $5/1M out + opus-4-8 → $5/1M in, $25/1M out + sonnet-4-6 → $3/1M in, $15/1M out + - This table is low-maintenance: Anthropic prices a specific model id once and + ships price changes as NEW model ids, so an existing id's rate does not move. + A new row is only needed when we adopt a new model — i.e. exactly when we'd be + changing CLASSIFY_MODEL anyway. + - Unknown model id (not in the table) → show the token counts but omit the ¢ + figure (or "cost: n/a"), never a guessed number. A stale table degrades + gracefully instead of lying. + +(Balance/spend indicator: dropped. The Anthropic API has no remaining-balance +endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is +the only cost surface.) \ No newline at end of file