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