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>
This commit is contained in:
Jean-Michel Tremblay 2026-06-19 07:06:15 -04:00
parent e723e7989d
commit 04d6e8177d
12 changed files with 476 additions and 37 deletions

View file

@ -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,

View file

@ -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

View file

@ -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"

View file

@ -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,

View file

@ -2,6 +2,7 @@
{{define "content"}}
<p class="ok">✓ Receipt saved</p>
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
<a class="button green" href="/">Add another</a>
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
{{end}}

View file

@ -18,6 +18,7 @@
<a href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
· {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
<br><small>{{.Filename}} — uploaded {{.When}}</small>
{{if .Attachments}}<br><small>attachments: {{range $i, $a := .Attachments}}{{if $i}}, {{end}}<a href="/attachment/{{$a.ID}}/file" target="_blank">{{$a.Filename}}</a>{{end}}</small>{{end}}
</li>
{{end}}
</ul>

View file

@ -30,6 +30,8 @@
<option value=""></option>
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
<label for="attachments">Additional files (optional)</label>
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
<div class="dup-warn" id="dup-warn" hidden>
<strong>Possible duplicate</strong> — a receipt with this date and amount is already saved:
<ul id="dup-list"></ul>

View file

@ -142,6 +142,12 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
}
}
// Optional extra files attached in the same submission (item 10). Read and
// validate them up front so a bad attachment re-renders the form before any
// row is written. They are saved after the receipt row exists (see below).
attachments, attErrs := readAttachments(r)
errs = append(errs, attErrs...)
if len(errs) > 0 {
view.Errors = errs
s.renderForm(w, view)
@ -176,16 +182,98 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Save each attachment now that the parent receipt id exists, keyed to the
// receipt's date+amount so the files sort alongside it.
var attachmentNames []string
for _, a := range attachments {
relPath, err := s.saveAttachmentFile(receiptDate, amountCents, a.ext, a.data)
if err != nil {
s.serverError(w, "save attachment", err)
return
}
if err := s.store.InsertAttachment(receipt.Attachment{
ID: newID(),
ReceiptID: id,
UploadedBy: sess.Subject,
UploadedAt: time.Now().UTC(),
FilePath: relPath,
ImageData: a.data,
FileSizeBytes: int64(len(a.data)),
OriginalFilename: a.filename,
MimeType: a.mimeType,
}); err != nil {
s.serverError(w, "insert attachment", err)
return
}
attachmentNames = append(attachmentNames, a.filename)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = confirmPage.ExecuteTemplate(w, "base", map[string]string{
"Category": labelFor(categoryID, cats),
"Who": whoLabel(personID, people),
"Amount": fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
"Date": dateStr,
"Filename": header.Filename,
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
Category: labelFor(categoryID, cats),
Who: whoLabel(personID, people),
Amount: fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
Date: dateStr,
Filename: header.Filename,
Attachments: attachmentNames,
})
}
// confirmView is the data for the post-save confirmation page.
type confirmView struct {
Category string
Who string
Amount string
Date string
Filename string
Attachments []string
}
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
type pendingAttachment struct {
data []byte
mimeType string
ext string
filename string
}
// readAttachments reads and validates the optional "attachments" multi-file field.
// Empty parts are skipped; invalid types accumulate into errs (and the upload is
// re-rendered, so nothing is written).
func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) {
if r.MultipartForm == nil {
return nil, nil
}
for _, fh := range r.MultipartForm.File["attachments"] {
af, err := fh.Open()
if err != nil {
errs = append(errs, "Could not read an attachment.")
continue
}
adata, err := io.ReadAll(af)
af.Close()
if err != nil {
errs = append(errs, "Could not read an attachment.")
continue
}
if len(adata) == 0 {
continue
}
amime := detectMime(adata)
if !allowedMime(amime) {
errs = append(errs, "Attachments must be images or PDFs.")
continue
}
out = append(out, pendingAttachment{
data: adata,
mimeType: amime,
ext: extFor(fh.Filename, amime),
filename: fh.Filename,
})
}
return out, errs
}
// parseLookupID parses s as an int64 and confirms it exists in the given set.
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
id, err := strconv.ParseInt(s, 10, 64)
@ -216,29 +304,41 @@ func whoLabel(id *int64, set []storage.Lookup) string {
return labelFor(*id, set)
}
// saveReceiptFile writes the bytes under
//
// <StorageDir>/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>
//
// using the receipt date and amount. When a file with the same date+amount+ext
// already exists (legitimately possible — duplicates can be approved), a _1, _2, …
// suffix is added to the stem. Exclusive create avoids two concurrent uploads
// racing onto the same name. Returns the storage-relative path actually written
// (forward-slash separated, as stored in receipts.file_path).
// receiptStem returns the dated, amount-tagged base name (no extension) shared by
// a receipt and its attachments, e.g. "06_08_42.50".
func receiptStem(date time.Time, amountCents int64) string {
return fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
}
// saveReceiptFile writes the receipt bytes under
// <StorageDir>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>.
func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
yearDir := fmt.Sprintf("%04d", date.Year())
if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, yearDir), 0o700); err != nil {
return s.saveBlobFile("receipts", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents), ext, data)
}
// saveAttachmentFile writes an attachment's bytes under
// <StorageDir>/attachments/<YYYY>/<MM>_<DD>_<dollars>.<cents>_att<ext>, keyed to
// the PARENT receipt's date and amount so an expense's files sort together.
func (s *Server) saveAttachmentFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
return s.saveBlobFile("attachments", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents)+"_att", ext, data)
}
// saveBlobFile writes data to <StorageDir>/<subdir>/<year>/<stem><ext>, adding a
// _1, _2, … suffix on the stem when the name is taken (legitimately possible —
// duplicates can be approved, and a receipt can have several attachments).
// Exclusive create avoids two concurrent uploads racing onto the same name.
// Returns the storage-relative path actually written (forward-slash separated).
func (s *Server) saveBlobFile(subdir, year, stem, ext string, data []byte) (string, error) {
if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, subdir, year), 0o700); err != nil {
return "", err
}
stem := fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
for i := 0; i < 10000; i++ {
name := stem
if i > 0 {
name += "_" + strconv.Itoa(i)
}
name += ext
rel := filepath.Join(yearDir, name)
rel := filepath.Join(subdir, year, name)
f, err := os.OpenFile(filepath.Join(s.cfg.StorageDir, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if os.IsExist(err) {
continue // name taken — try the next suffix

View file

@ -2,6 +2,7 @@ package web
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -107,9 +108,9 @@ func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) {
t.Fatalf("CountActive = %d, want 1", n)
}
// File written to disk under the receipt-year subdir with a dated, amount-
// tagged name (date 2026-06-01, amount 12.34 -> 2026/06_01_12.34.png).
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "2026"))
// File written to disk under receipts/<year> with a dated, amount-tagged name
// (date 2026-06-01, amount 12.34 -> receipts/2026/06_01_12.34.png).
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026"))
if err != nil {
t.Fatal(err)
}
@ -140,7 +141,7 @@ func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) {
post()
post() // same date + amount → must not overwrite the first file
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "2026"))
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026"))
if err != nil {
t.Fatal(err)
}
@ -153,6 +154,94 @@ func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) {
}
}
func TestUpload_WithAttachments(t *testing.T) {
s := testServerWithStore(t)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("amount", "12.34")
_ = mw.WriteField("receipt_date", "2026-06-01")
_ = mw.WriteField("category_id", aCategoryID(t, s))
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
rw.Write(fakePNG())
for i := 0; i < 2; i++ { // two extra files in the same submit
aw, _ := mw.CreateFormFile("attachments", fmt.Sprintf("page%d.png", i))
aw.Write(fakePNG())
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "attachment(s)") {
t.Errorf("confirm missing attachment listing: %s", rec.Body.String())
}
// Files written under attachments/2026 with parent-stem _att naming.
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "attachments", "2026"))
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
got[e.Name()] = true
}
if !got["06_01_12.34_att.png"] || !got["06_01_12.34_att_1.png"] {
t.Errorf("attachment files = %v, want _att and _att_1", got)
}
// Two attachment rows linked to the receipt; serving works.
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
}
atts, err := s.store.ListAttachmentMeta(rows[0].ID)
if err != nil {
t.Fatal(err)
}
if len(atts) != 2 {
t.Fatalf("attachments = %d, want 2", len(atts))
}
rec2 := get(t, s, "/attachment/"+atts[0].ID+"/file")
if rec2.Code != http.StatusOK || !strings.HasPrefix(rec2.Body.String(), "\x89PNG") {
t.Errorf("attachment serve failed: %d %q", rec2.Code, rec2.Body.String())
}
}
func TestUpload_RejectsBadAttachment(t *testing.T) {
s := testServerWithStore(t)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
_ = mw.WriteField("amount", "12.34")
_ = mw.WriteField("receipt_date", "2026-06-01")
_ = mw.WriteField("category_id", aCategoryID(t, s))
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
rw.Write(fakePNG())
bad, _ := mw.CreateFormFile("attachments", "notes.txt")
bad.Write([]byte("this is plain text, not an image or pdf"))
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(authCookie(t, s))
rec := httptest.NewRecorder()
s.Routes().ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), "Attachments must be images or PDFs") {
t.Errorf("expected attachment-type error, got: %s", rec.Body.String())
}
// Nothing should have been written.
if n, _ := s.store.CountActive(); n != 0 {
t.Errorf("CountActive = %d, want 0 (rejected upload)", n)
}
}
func TestUpload_BadAmount_RerendersWithErrorNoRow(t *testing.T) {
s := testServerWithStore(t)
body, ct := multipartUpload(t,

View file

@ -75,12 +75,19 @@ type recentView struct {
// recentRow is one listing row with display-formatted fields.
type recentRow struct {
ID string
Date string
Amount string
Category string
Who string
When string
Filename string
Attachments []attachLink
}
// attachLink is a viewable attachment reference shown under a recent row.
type attachLink struct {
ID string
Date string
Amount string
Category string
Who string
When string
Filename string
}
@ -108,14 +115,24 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
view := recentView{Title: title, ByLabel: byLabel, BasePath: basePath}
for _, row := range rows {
atts, err := s.store.ListAttachmentMeta(row.ID)
if err != nil {
s.serverError(w, "list attachments", err)
return
}
var links []attachLink
for _, a := range atts {
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
}
view.Rows = append(view.Rows, recentRow{
ID: row.ID,
Date: row.ReceiptDate.Format(dateLayout),
Amount: dollars(row.AmountCents),
Category: row.Category,
Who: row.Who,
When: row.UploadedAt.Local().Format("2006-01-02 15:04"),
Filename: row.OriginalFilename,
ID: row.ID,
Date: row.ReceiptDate.Format(dateLayout),
Amount: dollars(row.AmountCents),
Category: row.Category,
Who: row.Who,
When: row.UploadedAt.Local().Format("2006-01-02 15:04"),
Filename: row.OriginalFilename,
Attachments: links,
})
}
if hasMore {
@ -194,6 +211,19 @@ func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
}
// handleAttachmentFile serves a receipt attachment's bytes from the DB blob.
func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.store.GetAttachment(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", a.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
}
// dollars formats integer cents as a plain dollar string, e.g. 1234 -> "12.34".
func dollars(cents int64) string {
if cents < 0 {

View file

@ -74,6 +74,7 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /recent", s.requireAuth(http.HandlerFunc(s.handleRecentUploads)))
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))

68
spec.md
View file

@ -240,4 +240,70 @@ storage root, so the files directory is browsable on its own:
from the blob regardless of the on-disk name.
- Applies to NEW uploads only — existing UUID-named files keep their file_path;
no backfill/rename of historical files in scope.
- saveFile must create the year subdirectory (MkdirAll) before writing.
- saveFile must create the year subdirectory (MkdirAll) before writing.
10. Additional attachments on a receipt
When uploading a receipt, the user can also attach one or more EXTRA files in the
SAME submission (a second page, an itemized list, an EOB, a photo from another
angle). Attachments are supplementary files for the same HSA expense — they are
not standalone receipts and carry no amount/date/category/who of their own; they
inherit the parent receipt's identity (including its date, which drives their
on-disk name). Adding attachments to an ALREADY-SAVED receipt is not supported yet
(no edit/detail page) — attachments are captured only at receipt-creation time.
Data model — new `attachments` table:
- id (UUID)
- receipt_id (FK → receipts.id; the parent expense)
- uploaded_by (Authelia username/email)
- uploaded_at (server timestamp)
- file_path (relative path on disk — see layout below)
- image_data (BLOB — the bytes, stored in the DB too, same as receipts)
- file_size_bytes
- original_filename
- mime_type
- deleted_at (nullable — soft delete)
Index on receipt_id (and on deleted_at) for listing a receipt's live attachments.
Storage layout change — split receipts and attachments under the root:
- Receipts move from <STORAGE_DIR>/<YYYY>/… to <STORAGE_DIR>/receipts/<YYYY>/…
(the item-9 dated name is unchanged; only the "receipts/" prefix is added).
- Attachments go to <STORAGE_DIR>/attachments/<YYYY>/…, named from the PARENT
receipt's stem plus an attachment marker, e.g. a JPEG attached to the
$42.50 receipt dated 2026-06-08 →
attachments/2026/06_08_42.50_att.jpeg
with the same exclusive-create disambiguation as receipts: the second
attachment becomes _att_1, the third _att_2, etc. (Year/MM/DD/amount come from
the parent receipt, so an expense's receipt and its attachments sort together.)
- Same dual-write as receipts: bytes on disk AND as a DB blob, so the single .db
export stays a complete dataset (receipts + attachments + images).
- Existing receipts keep their stored file_path verbatim — file_path is the
source of truth for serving location, so old (ROOT/<YYYY>/…) and new
(ROOT/receipts/<YYYY>/…) paths coexist with no backfill. (In practice the DB
was reset, so there are no legacy files to move.)
UI and endpoints:
- The upload form gets an additional, OPTIONAL multi-file input ("Additional
files", accept images + PDF, multiple). These ride along with the normal
receipt submission to POST /upload — there is no separate attach endpoint.
- Submission order: the receipt row is inserted first (so its id exists), then
each attached file is saved and linked to it. Same MIME allowlist (images +
PDF) and per-file size cap (MAX_UPLOAD_MB) as the receipt. No AI runs on
attachments. AI auto-fill still reads only the primary receipt image.
- The confirm page lists the saved receipt plus the count/filenames of any
attachments.
- GET /attachment/{id}/file serves an attachment's bytes from the DB blob (mirror
of GET /receipt/{id}/file), so the recent list can link them.
Lifecycle / interactions:
- Soft-deleting a receipt also hides its attachments (filter attachments by the
parent's deleted_at, or soft-delete the children alongside the parent).
- Attachments never affect Tally, duplicate detection, or the receipt counts —
those operate on receipts only.
- Export: attachments ride along automatically in the .db blob export; an archive
/zip export (if/when added) lists them under their receipt.
Out of scope (for now):
- Adding attachments to an already-saved receipt (would need an edit/detail page).
- No AI parsing of attachments; no per-attachment metadata beyond the file.
- No reordering UI beyond upload order.