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 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-18 22:04:42 -04:00
parent 8b7252dad4
commit 5c21f131fd
21 changed files with 1291 additions and 9 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

263
internal/storage/query.go Normal file
View file

@ -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()
}

View file

@ -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))
}

View file

@ -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 = &cents
}
if id, ok := idForLabel(sug.Category, cats); ok {
out.CategoryID = &id

View file

@ -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;

View file

@ -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")
)

View file

@ -0,0 +1,30 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
<header>
<span><a href="/">← Add receipt</a></span>
<span><a href="/tally">Tally</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
</header>
<h1>{{.Title}}</h1>
<p class="tabs">
<a href="/recent"{{if eq .BasePath "/recent"}} class="active"{{end}}>By upload date</a> ·
<a href="/recent/receipts"{{if eq .BasePath "/recent/receipts"}} class="active"{{end}}>By receipt date</a>
</p>
{{if not .Rows}}
<p><small>No receipts to show.</small></p>
{{else}}
<ul class="recent">
{{range .Rows}}
<li>
<a href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
· {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
<br><small>{{.Filename}} — uploaded {{.When}}</small>
</li>
{{end}}
</ul>
<p class="pager">
{{if .PrevURL}}<a href="{{.PrevURL}}">← Newer</a>{{end}}
{{if and .PrevURL .NextURL}} · {{end}}
{{if .NextURL}}<a href="{{.NextURL}}">Load next 10 →</a>{{end}}
</p>
{{end}}
{{end}}

View file

@ -0,0 +1,41 @@
{{define "title"}}Tally{{end}}
{{define "content"}}
<header>
<span><a href="/">← Add receipt</a></span>
<span><a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
</header>
<h1>Tally</h1>
{{if .Empty}}
<p><small>No receipts yet. Totals will appear here once you add some.</small></p>
{{else}}
<p><small>Totals by person and receipt-date year, in dollars. Right column is each
person's total; bottom row is each year's total.</small></p>
<div class="tally-wrap">
<table class="tally">
<thead>
<tr>
<th>Who</th>
{{range .Years}}<th class="num">{{.}}</th>{{end}}
<th class="num total">Total</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<th>{{.Person}}</th>
{{range .Cells}}<td class="num">{{if .}}{{.}}{{else}}<span class="muted"></span>{{end}}</td>{{end}}
<td class="num total">{{.Total}}</td>
</tr>
{{end}}
</tbody>
<tfoot>
<tr>
<th>Total</th>
{{range .YearTotals}}<td class="num total">{{.}}</td>{{end}}
<td class="num grand">{{.Grand}}</td>
</tr>
</tfoot>
</table>
</div>
{{end}}
{{end}}

View file

@ -2,13 +2,19 @@
{{define "content"}}
<header>
<span>{{.Subject}}</span>
<span><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
</header>
<h1>Add receipt</h1>
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
<form method="post" action="/upload" enctype="multipart/form-data">
<form method="post" action="/upload" enctype="multipart/form-data" id="upload-form">
<label for="receipt">Receipt photo or PDF</label>
<input id="receipt" type="file" name="receipt" accept="image/*,application/pdf" capture="environment" required>
{{if .ClassifyEnabled}}
<label class="check"><input type="checkbox" id="skip-ai"> Skip AI auto-fill (enter fields by hand)</label>
<p class="footnote" id="classify-note">Receipt is read automatically by <code>{{.ClassifyModel}}</code> to pre-fill the fields below — review before submitting.</p>
{{else}}
<p class="footnote">Auto-fill is off (no API key configured). Enter the fields manually.</p>
{{end}}
<label for="amount">Amount ($)</label>
<input id="amount" type="number" name="amount" step="0.01" min="0.01" inputmode="decimal" value="{{.Amount}}" required>
<label for="receipt_date">Date on receipt</label>
@ -24,6 +30,118 @@
<option value=""></option>
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
<button type="submit" class="primary">Submit</button>
<div class="dup-warn" id="dup-warn" hidden>
<strong>Possible duplicate</strong> — a receipt with this date and amount is already saved:
<ul id="dup-list"></ul>
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
</div>
<button type="submit" class="primary" id="submit-btn">Submit</button>
</form>
<script>
(function () {
var form = document.getElementById("upload-form");
var amount = document.getElementById("amount");
var dateEl = document.getElementById("receipt_date");
var dupWarn = document.getElementById("dup-warn");
var dupList = document.getElementById("dup-list");
var dupAck = document.getElementById("dup-ack");
var submitBtn = document.getElementById("submit-btn");
var dupVisible = false;
// --- Duplicate check (runs whenever date + amount are both known) ---
function updateSubmitState() {
submitBtn.disabled = dupVisible && !dupAck.checked;
}
dupAck.addEventListener("change", updateSubmitState);
function checkDuplicates() {
var a = amount.value.trim(), d = dateEl.value.trim();
if (!a || !d) { hideDup(); return; }
var url = "/duplicates?date=" + encodeURIComponent(d) + "&amount=" + encodeURIComponent(a);
fetch(url)
.then(function (r) { return r.ok ? r.json() : { matches: [] }; })
.then(function (data) {
if (!data.matches || !data.matches.length) { hideDup(); return; }
dupList.innerHTML = "";
data.matches.forEach(function (m) {
var li = document.createElement("li");
var who = m.who ? " · " + m.who : "";
li.textContent = "$" + m.amount + " · " + m.date + " · " + m.category + who +
" — " + m.filename + " (uploaded " + m.uploaded + " by " + m.by + ")";
dupList.appendChild(li);
});
dupWarn.hidden = false;
dupVisible = true;
dupAck.checked = false;
updateSubmitState();
})
.catch(hideDup);
}
function hideDup() {
dupWarn.hidden = true;
dupVisible = false;
updateSubmitState();
}
amount.addEventListener("change", checkDuplicates);
dateEl.addEventListener("change", checkDuplicates);
checkDuplicates(); // in case the form was pre-filled (e.g. re-render after error)
})();
</script>
{{if .ClassifyEnabled}}
<script>
(function () {
var fileInput = document.getElementById("receipt");
var skip = document.getElementById("skip-ai");
var note = document.getElementById("classify-note");
var model = {{.ClassifyModel}};
var baseNote = note.innerHTML;
skip.addEventListener("change", function () {
note.style.opacity = skip.checked ? "0.5" : "";
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand.";
else note.innerHTML = baseNote;
});
fileInput.addEventListener("change", function () {
var file = fileInput.files[0];
if (!file || skip.checked) return;
note.style.opacity = "";
note.textContent = "Reading receipt with " + model + "…";
var body = new FormData();
body.append("receipt", file);
fetch("/classify", { method: "POST", body: body })
.then(function (resp) {
if (!resp.ok) throw new Error("classify failed (" + resp.status + ")");
return resp.json();
})
.then(function (d) {
if (d.amount) setValue("amount", d.amount);
if (d.date) setValue("receipt_date", d.date);
if (d.category_id != null) setValue("category_id", String(d.category_id));
if (d.person_id != null) setValue("person_id", String(d.person_id));
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
// Pre-filled date+amount may match an existing receipt — re-check.
document.getElementById("receipt_date").dispatchEvent(new Event("change"));
})
.catch(function (err) {
note.innerHTML = baseNote;
var e = document.createElement("span");
e.className = "err-inline";
e.textContent = " Auto-fill failed: " + err.message + ". Enter the fields manually.";
note.appendChild(e);
});
});
function setValue(id, val) {
var el = document.getElementById(id);
if (el) el.value = val;
}
})();
</script>
{{end}}
{{end}}

View file

@ -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)

203
internal/web/views.go Normal file
View file

@ -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)
}

118
internal/web/views_test.go Normal file
View file

@ -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")
}
}

View file

@ -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)))

View file

@ -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"

18
scripts/run.sh Executable file
View file

@ -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 "$@"

105
spec.md
View file

@ -107,3 +107,108 @@ Reimbursement tracking (paid vs pending status)
In-place editing of existing receipts
Multi-tenancy or per-user data isolation
Notification / reminders
================================================================================
v2 — additions
================================================================================
These supersede the v1 "out of scope" entries for OCR-assisted entry (now present
via the classifier) and "Reports, totals, dashboards" (see Tally below). Same two
users, same auth model, same storage. Mobile-first still applies.
1. Skip-AI toggle on upload
A control on the upload form lets the user opt out of AI parsing for the current
receipt — for receipts they know are too hard to read, or to avoid spending an API
call on a bad result.
- Default: AI parsing ON (auto-fill runs when a file is attached).
- When "skip AI" is selected, NO /classify call is made; the user fills amount,
date, category, and who by hand.
- The model footnote stays visible but reads as disabled when skip is selected,
so the user always knows whether a call will happen and which model it uses.
- Skipping is per-upload, not a saved preference.
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.)