diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index f9f3023..57ff2dd 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -25,6 +25,23 @@ type Receipt struct { DeletedAt *time.Time } +// Attachment is a supplementary file belonging to a receipt (a second page, an +// itemized list, an EOB). It carries no amount/date/category/who of its own — it +// inherits the parent receipt's identity. Stored dual-write like receipts: bytes +// on disk and as a DB blob. +type Attachment struct { + ID string + ReceiptID string + UploadedBy string + UploadedAt time.Time + FilePath string + ImageData []byte + FileSizeBytes int64 + OriginalFilename string + MimeType string + DeletedAt *time.Time +} + // ParseAmountCents parses a positive dollar amount into integer cents without // using floating point. Accepts an optional leading "$", surrounding spaces, and // thousands separators. Rejects empty, non-numeric, more than two decimal places, diff --git a/internal/storage/query.go b/internal/storage/query.go index e4e3cbb..2f6b61a 100644 --- a/internal/storage/query.go +++ b/internal/storage/query.go @@ -1,10 +1,13 @@ 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 @@ -110,6 +113,74 @@ func scanReceiptRow(sc rowScanner) (ReceiptRow, error) { 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 diff --git a/internal/storage/query_test.go b/internal/storage/query_test.go index b1382d9..8c7e7fd 100644 --- a/internal/storage/query_test.go +++ b/internal/storage/query_test.go @@ -3,6 +3,8 @@ package storage import ( "testing" "time" + + "maisym.com/hsa/internal/receipt" ) // insertOn inserts a receipt with the given id, person, category, amount and @@ -187,6 +189,52 @@ func TestReconcilePeople_LeavesCustomAndCanonicalAlone(t *testing.T) { } } +func TestAttachments_InsertGetList(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + insertOn(t, s, "rcpt", cid, nil, 1000, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + + att := receipt.Attachment{ + ID: "att1", + ReceiptID: "rcpt", + UploadedBy: "jm@example.com", + UploadedAt: time.Now().UTC().Truncate(time.Second), + FilePath: "attachments/2026/06_01_10.00_att.png", + ImageData: []byte("\x89PNGblob"), + FileSizeBytes: 8, + OriginalFilename: "page1.png", + MimeType: "image/png", + } + if err := s.InsertAttachment(att); err != nil { + t.Fatalf("InsertAttachment: %v", err) + } + + got, err := s.GetAttachment("att1") + if err != nil { + t.Fatal(err) + } + if got.ReceiptID != "rcpt" || string(got.ImageData) != "\x89PNGblob" || got.OriginalFilename != "page1.png" { + t.Errorf("GetAttachment mismatch: %+v", got) + } + + metas, err := s.ListAttachmentMeta("rcpt") + if err != nil { + t.Fatal(err) + } + if len(metas) != 1 || metas[0].ID != "att1" || metas[0].OriginalFilename != "page1.png" { + t.Errorf("ListAttachmentMeta = %+v", metas) + } +} + +func TestInsertAttachment_RejectsUnknownReceiptFK(t *testing.T) { + s := newTestStore(t) + att := receipt.Attachment{ID: "x", ReceiptID: "nope", UploadedAt: time.Now(), + FilePath: "p", ImageData: []byte("b"), OriginalFilename: "f", MimeType: "image/png"} + if err := s.InsertAttachment(att); err == nil { + t.Error("expected FK violation for unknown receipt_id") + } +} + // padID returns a deterministic 36-char UUID-ish id from an index. func padID(i int) string { const base = "00000000-0000-0000-0000-0000000000" diff --git a/internal/storage/storage.go b/internal/storage/storage.go index a52bd88..b7763d3 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -52,6 +52,19 @@ CREATE TABLE IF NOT EXISTS receipts ( deleted_at TEXT ); CREATE INDEX IF NOT EXISTS idx_receipts_active ON receipts(deleted_at); +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + receipt_id TEXT NOT NULL REFERENCES receipts(id), + uploaded_by TEXT NOT NULL, + uploaded_at TEXT NOT NULL, + file_path TEXT NOT NULL, + image_data BLOB NOT NULL, + file_size_bytes INTEGER NOT NULL, + original_filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + deleted_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at); ` // Open opens (creating if needed) the SQLite database at path, applies the schema, diff --git a/internal/web/templates/confirm.html b/internal/web/templates/confirm.html index a3fbe12..4c6062a 100644 --- a/internal/web/templates/confirm.html +++ b/internal/web/templates/confirm.html @@ -2,6 +2,7 @@ {{define "content"}}

✓ Receipt saved

{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}
{{.Filename}}

+{{if .Attachments}}

+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}

{{end}} Add another

Manage · Export · Log out

{{end}} diff --git a/internal/web/templates/recent.html b/internal/web/templates/recent.html index 64e9082..8749329 100644 --- a/internal/web/templates/recent.html +++ b/internal/web/templates/recent.html @@ -18,6 +18,7 @@ ${{.Amount}} · {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
{{.Filename}} — uploaded {{.When}} + {{if .Attachments}}
attachments: {{range $i, $a := .Attachments}}{{if $i}}, {{end}}{{$a.Filename}}{{end}}{{end}} {{end}} diff --git a/internal/web/templates/upload.html b/internal/web/templates/upload.html index 5ac742e..69e6d58 100644 --- a/internal/web/templates/upload.html +++ b/internal/web/templates/upload.html @@ -30,6 +30,8 @@ {{range .People}}{{end}} + +