2026-06-19 02:04:42 +00:00
|
|
|
|
package storage
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-19 11:06:15 +00:00
|
|
|
|
"database/sql"
|
2026-06-19 02:04:42 +00:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"sort"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-06-19 11:06:15 +00:00
|
|
|
|
|
|
|
|
|
|
"maisym.com/hsa/internal/receipt"
|
2026-06-19 02:04:42 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 02:04:42 +00:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|