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
|
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
|
// ParseAmountCents parses a positive dollar amount into integer cents without
|
||||||
// using floating point. Accepts an optional leading "$", surrounding spaces, and
|
// using floating point. Accepts an optional leading "$", surrounding spaces, and
|
||||||
// thousands separators. Rejects empty, non-numeric, more than two decimal places,
|
// thousands separators. Rejects empty, non-numeric, more than two decimal places,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/receipt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReceiptRow is a display row for listings and duplicate warnings. It carries the
|
// 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
|
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).
|
// TallyRow is one person's totals across years (cents).
|
||||||
type TallyRow struct {
|
type TallyRow struct {
|
||||||
Person string
|
Person string
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package storage
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/receipt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// insertOn inserts a receipt with the given id, person, category, amount and
|
// 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.
|
// padID returns a deterministic 36-char UUID-ish id from an index.
|
||||||
func padID(i int) string {
|
func padID(i int) string {
|
||||||
const base = "00000000-0000-0000-0000-0000000000"
|
const base = "00000000-0000-0000-0000-0000000000"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,19 @@ CREATE TABLE IF NOT EXISTS receipts (
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_receipts_active ON receipts(deleted_at);
|
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,
|
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<p class="ok">✓ Receipt saved</p>
|
<p class="ok">✓ Receipt saved</p>
|
||||||
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</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>
|
<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>
|
<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}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<a href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
<a href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
||||||
· {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
|
· {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
|
||||||
<br><small>{{.Filename}} — uploaded {{.When}}</small>
|
<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>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</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>
|
<div class="dup-warn" id="dup-warn" hidden>
|
||||||
<strong>Possible duplicate</strong> — a receipt with this date and amount is already saved:
|
<strong>Possible duplicate</strong> — a receipt with this date and amount is already saved:
|
||||||
<ul id="dup-list"></ul>
|
<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 {
|
if len(errs) > 0 {
|
||||||
view.Errors = errs
|
view.Errors = errs
|
||||||
s.renderForm(w, view)
|
s.renderForm(w, view)
|
||||||
|
|
@ -176,16 +182,98 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = confirmPage.ExecuteTemplate(w, "base", map[string]string{
|
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
||||||
"Category": labelFor(categoryID, cats),
|
Category: labelFor(categoryID, cats),
|
||||||
"Who": whoLabel(personID, people),
|
Who: whoLabel(personID, people),
|
||||||
"Amount": fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
|
Amount: fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
|
||||||
"Date": dateStr,
|
Date: dateStr,
|
||||||
"Filename": header.Filename,
|
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.
|
// parseLookupID parses s as an int64 and confirms it exists in the given set.
|
||||||
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
|
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
|
||||||
id, err := strconv.ParseInt(s, 10, 64)
|
id, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
|
@ -216,29 +304,41 @@ func whoLabel(id *int64, set []storage.Lookup) string {
|
||||||
return labelFor(*id, set)
|
return labelFor(*id, set)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveReceiptFile writes the bytes under
|
// receiptStem returns the dated, amount-tagged base name (no extension) shared by
|
||||||
//
|
// a receipt and its attachments, e.g. "06_08_42.50".
|
||||||
// <StorageDir>/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>
|
func receiptStem(date time.Time, amountCents int64) string {
|
||||||
//
|
return fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
|
||||||
// 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
|
// saveReceiptFile writes the receipt bytes under
|
||||||
// racing onto the same name. Returns the storage-relative path actually written
|
// <StorageDir>/receipts/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>.
|
||||||
// (forward-slash separated, as stored in receipts.file_path).
|
|
||||||
func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
|
func (s *Server) saveReceiptFile(date time.Time, amountCents int64, ext string, data []byte) (string, error) {
|
||||||
yearDir := fmt.Sprintf("%04d", date.Year())
|
return s.saveBlobFile("receipts", fmt.Sprintf("%04d", date.Year()), receiptStem(date, amountCents), ext, data)
|
||||||
if err := os.MkdirAll(filepath.Join(s.cfg.StorageDir, yearDir), 0o700); err != nil {
|
}
|
||||||
|
|
||||||
|
// 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
|
return "", err
|
||||||
}
|
}
|
||||||
stem := fmt.Sprintf("%02d_%02d_%d.%02d", int(date.Month()), date.Day(), amountCents/100, amountCents%100)
|
|
||||||
|
|
||||||
for i := 0; i < 10000; i++ {
|
for i := 0; i < 10000; i++ {
|
||||||
name := stem
|
name := stem
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
name += "_" + strconv.Itoa(i)
|
name += "_" + strconv.Itoa(i)
|
||||||
}
|
}
|
||||||
name += ext
|
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)
|
f, err := os.OpenFile(filepath.Join(s.cfg.StorageDir, rel), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
if os.IsExist(err) {
|
if os.IsExist(err) {
|
||||||
continue // name taken — try the next suffix
|
continue // name taken — try the next suffix
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
|
@ -107,9 +108,9 @@ func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) {
|
||||||
t.Fatalf("CountActive = %d, want 1", n)
|
t.Fatalf("CountActive = %d, want 1", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File written to disk under the receipt-year subdir with a dated, amount-
|
// File written to disk under receipts/<year> with a dated, amount-tagged name
|
||||||
// tagged name (date 2026-06-01, amount 12.34 -> 2026/06_01_12.34.png).
|
// (date 2026-06-01, amount 12.34 -> receipts/2026/06_01_12.34.png).
|
||||||
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "2026"))
|
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +141,7 @@ func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) {
|
||||||
post()
|
post()
|
||||||
post() // same date + amount → must not overwrite the first file
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
func TestUpload_BadAmount_RerendersWithErrorNoRow(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
body, ct := multipartUpload(t,
|
body, ct := multipartUpload(t,
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,19 @@ type recentView struct {
|
||||||
|
|
||||||
// recentRow is one listing row with display-formatted fields.
|
// recentRow is one listing row with display-formatted fields.
|
||||||
type recentRow struct {
|
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
|
ID string
|
||||||
Date string
|
|
||||||
Amount string
|
|
||||||
Category string
|
|
||||||
Who string
|
|
||||||
When string
|
|
||||||
Filename 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}
|
view := recentView{Title: title, ByLabel: byLabel, BasePath: basePath}
|
||||||
for _, row := range rows {
|
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{
|
view.Rows = append(view.Rows, recentRow{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Date: row.ReceiptDate.Format(dateLayout),
|
Date: row.ReceiptDate.Format(dateLayout),
|
||||||
Amount: dollars(row.AmountCents),
|
Amount: dollars(row.AmountCents),
|
||||||
Category: row.Category,
|
Category: row.Category,
|
||||||
Who: row.Who,
|
Who: row.Who,
|
||||||
When: row.UploadedAt.Local().Format("2006-01-02 15:04"),
|
When: row.UploadedAt.Local().Format("2006-01-02 15:04"),
|
||||||
Filename: row.OriginalFilename,
|
Filename: row.OriginalFilename,
|
||||||
|
Attachments: links,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if hasMore {
|
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))
|
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".
|
// dollars formats integer cents as a plain dollar string, e.g. 1234 -> "12.34".
|
||||||
func dollars(cents int64) string {
|
func dollars(cents int64) string {
|
||||||
if cents < 0 {
|
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", s.requireAuth(http.HandlerFunc(s.handleRecentUploads)))
|
||||||
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
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 /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("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
||||||
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
||||||
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
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.
|
from the blob regardless of the on-disk name.
|
||||||
- Applies to NEW uploads only — existing UUID-named files keep their file_path;
|
- Applies to NEW uploads only — existing UUID-named files keep their file_path;
|
||||||
no backfill/rename of historical files in scope.
|
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