hsa-app/internal/storage/query.go
Jean-Michel Tremblay 5c21f131fd 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>
2026-06-18 22:04:42 -04:00

263 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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