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"}}
+
+ 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"}} +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.
+| Who | + {{range .Years}}{{.}} | {{end}} +Total | +
|---|---|---|
| {{.Person}} | + {{range .Cells}}{{if .}}{{.}}{{else}}—{{end}} | {{end}} +{{.Total}} | +
| Total | + {{range .YearTotals}}{{.}} | {{end}} +{{.Grand}} | +