hsa-app/internal/storage/query.go
Jean-Michel Tremblay 04d6e8177d Add receipt attachments captured at upload time (spec item 10)
- New attachments table (FK to receipts), dual-write: file on disk + DB blob.
- On-disk layout split: receipts under <ROOT>/receipts/<YYYY>/..., attachments
  under <ROOT>/attachments/<YYYY>/..., attachments named from the parent
  receipt's date+amount stem (_att, _att_1, ...).
- Upload form gains an optional multi-file "Additional files" field; the files
  ride along with POST /upload, saved after the receipt row exists. No AI runs
  on attachments; primary-image auto-fill unchanged.
- GET /attachment/{id}/file serves blobs; confirm page lists them; recent list
  links them. Adding attachments to an already-saved receipt is not yet supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:06:15 -04:00

334 lines
11 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 (
"database/sql"
"fmt"
"sort"
"strings"
"time"
"maisym.com/hsa/internal/receipt"
)
// 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
}
// InsertAttachment stores a receipt attachment (metadata + blob).
func (s *Store) InsertAttachment(a receipt.Attachment) error {
_, err := s.db.Exec(
`INSERT INTO attachments
(id, receipt_id, uploaded_by, uploaded_at, file_path, image_data,
file_size_bytes, original_filename, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, a.ReceiptID, a.UploadedBy, a.UploadedAt.UTC().Format(rfc3339),
a.FilePath, a.ImageData, a.FileSizeBytes, a.OriginalFilename, a.MimeType,
)
if err != nil {
return fmt.Errorf("insert attachment: %w", err)
}
return nil
}
// GetAttachment returns the attachment with the given id, including its blob.
func (s *Store) GetAttachment(id string) (receipt.Attachment, error) {
row := s.db.QueryRow(
`SELECT id, receipt_id, uploaded_by, uploaded_at, file_path, image_data,
file_size_bytes, original_filename, mime_type, deleted_at
FROM attachments WHERE id = ?`, id)
var a receipt.Attachment
var uploadedAt string
var deletedAt sql.NullString
if err := row.Scan(&a.ID, &a.ReceiptID, &a.UploadedBy, &uploadedAt, &a.FilePath,
&a.ImageData, &a.FileSizeBytes, &a.OriginalFilename, &a.MimeType, &deletedAt); err != nil {
return receipt.Attachment{}, fmt.Errorf("get attachment: %w", err)
}
a.UploadedAt, _ = time.Parse(rfc3339, uploadedAt)
if deletedAt.Valid {
if t, err := time.Parse(rfc3339, deletedAt.String); err == nil {
a.DeletedAt = &t
}
}
return a, nil
}
// AttachmentMeta is the lightweight (no-blob) attachment info for listings.
type AttachmentMeta struct {
ID string
OriginalFilename string
}
// ListAttachmentMeta returns the live (non-deleted) attachments of a receipt,
// oldest first, without loading the blobs.
func (s *Store) ListAttachmentMeta(receiptID string) ([]AttachmentMeta, error) {
rows, err := s.db.Query(
`SELECT id, original_filename FROM attachments
WHERE receipt_id = ? AND deleted_at IS NULL
ORDER BY uploaded_at, id`, receiptID)
if err != nil {
return nil, fmt.Errorf("list attachments: %w", err)
}
defer rows.Close()
var out []AttachmentMeta
for rows.Next() {
var m AttachmentMeta
if err := rows.Scan(&m.ID, &m.OriginalFilename); err != nil {
return nil, fmt.Errorf("scan attachment: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// 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()
}