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:
parent
e723e7989d
commit
04d6e8177d
12 changed files with 476 additions and 37 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
68
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.
|
||||
- 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.
|
||||
Loading…
Reference in a new issue