Possible duplicate — a receipt with this date and amount is already saved:
diff --git a/internal/web/upload.go b/internal/web/upload.go
index cfff11d..70f2b7b 100644
--- a/internal/web/upload.go
+++ b/internal/web/upload.go
@@ -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
-//
-// //_
_.
-//
-// 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
+// /receipts//_
_._att, 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 ///, 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
diff --git a/internal/web/upload_test.go b/internal/web/upload_test.go
index c5b8a1a..a3d8e4c 100644
--- a/internal/web/upload_test.go
+++ b/internal/web/upload_test.go
@@ -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/ 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,
diff --git a/internal/web/views.go b/internal/web/views.go
index 44ed3a2..c88ee8e 100644
--- a/internal/web/views.go
+++ b/internal/web/views.go
@@ -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 {
diff --git a/internal/web/web.go b/internal/web/web.go
index c144d9b..40abc98 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -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)))
diff --git a/spec.md b/spec.md
index 3000335..5543e82 100644
--- a/spec.md
+++ b/spec.md
@@ -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.
\ No newline at end of file
+ - 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 //… to /receipts//…
+ (the item-9 dated name is unchanged; only the "receipts/" prefix is added).
+ - Attachments go to /attachments//…, 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//…) and new
+ (ROOT/receipts//…) 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.
\ No newline at end of file