Store receipt files under year/MM_DD_dollars.cents naming

Replace flat UUID filenames with a browsable, dated layout (spec.md item 9):
<STORAGE_DIR>/<YYYY>/<MM>_<DD>_<dollars>.<cents><ext>, from the receipt date
and amount. Same date+amount collisions get a _1, _2, … suffix via exclusive
create (race-safe). New uploads only; serving is unaffected (blob-backed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-18 22:10:41 -04:00
parent 5c21f131fd
commit e723e7989d
3 changed files with 111 additions and 13 deletions

View file

@ -148,11 +148,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
return
}
// Dual-write: file on disk (UUID name) + blob in DB.
// Dual-write: file on disk (dated, amount-tagged name) + blob in DB.
id := newID()
ext := extFor(header.Filename, mimeType)
relPath := id + ext
if err := s.saveFile(relPath, data); err != nil {
relPath, err := s.saveReceiptFile(receiptDate, amountCents, ext, data)
if err != nil {
s.serverError(w, "save file", err)
return
}
@ -216,11 +216,47 @@ func whoLabel(id *int64, set []storage.Lookup) string {
return labelFor(*id, set)
}
func (s *Server) saveFile(relPath string, data []byte) error {
if err := os.MkdirAll(s.cfg.StorageDir, 0o700); err != nil {
return err
// 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).
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 "", err
}
return os.WriteFile(filepath.Join(s.cfg.StorageDir, relPath), data, 0o600)
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)
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
}
if err != nil {
return "", err
}
_, werr := f.Write(data)
cerr := f.Close()
if werr != nil {
return "", werr
}
if cerr != nil {
return "", cerr
}
return filepath.ToSlash(rel), nil
}
return "", fmt.Errorf("could not find a free filename for %s", stem)
}
func detectMime(data []byte) string {

View file

@ -107,16 +107,49 @@ func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) {
t.Fatalf("CountActive = %d, want 1", n)
}
// File written to disk (dual-write).
entries, err := os.ReadDir(s.cfg.StorageDir)
// 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"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("storage dir has %d files, want 1", len(entries))
t.Fatalf("year dir has %d files, want 1", len(entries))
}
if !strings.HasSuffix(entries[0].Name(), ".png") {
t.Errorf("stored file name = %q, want .png suffix", entries[0].Name())
if entries[0].Name() != "06_01_12.34.png" {
t.Errorf("stored file name = %q, want 06_01_12.34.png", entries[0].Name())
}
}
func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) {
s := testServerWithStore(t)
cat := aCategoryID(t, s)
post := func() {
body, ct := multipartUpload(t,
map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": cat},
"receipt", "receipt.png", fakePNG())
req := httptest.NewRequest(http.MethodPost, "/upload", body)
req.Header.Set("Content-Type", ct)
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())
}
}
post()
post() // same date + amount → must not overwrite the first file
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "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.png"] || !got["06_01_12.34_1.png"] {
t.Errorf("expected both base and _1 files, got %v", got)
}
}

29
spec.md
View file

@ -212,3 +212,32 @@ Where the rates come from (this is the only maintenance cost of the feature):
(Balance/spend indicator: dropped. The Anthropic API has no remaining-balance
endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is
the only cost surface.)
9. Human-readable on-disk file layout
Replace the flat UUID filenames with a dated, amount-tagged layout under the
storage root, so the files directory is browsable on its own:
<STORAGE_DIR>/<YYYY>/<MM>_<DD>_<dollars>.<cents>.<ext>
- Year folder and MM/DD come from the RECEIPT date (not upload date); dollars
and cents come from amount_cents (cents zero-padded to two digits, dollars not
padded). Example: a $42.50 JPEG dated 2026-06-08 → 2026/06_08_42.50.jpeg.
- The amount's decimal dot and the extension dot coexist fine — the extension is
just the final dot-segment ("jpeg"); the stem is "06_08_42.50". (If that ever
feels ambiguous, the accepted alternatives are an underscore "06_08_42_50" or
bare cents "06_08_4250" — pick one and keep it consistent.)
- All path components derive only from the date and amount (digits, underscores,
one dot), never from the user-supplied original filename, so there is no path-
traversal surface. original_filename stays as metadata in the DB.
- Collisions (same date + amount + ext — legitimately possible since duplicates
can be approved) get a numeric suffix on the STEM, starting at _1: the second
file becomes "06_08_42.50_1.jpeg", the third "_2", etc. Use exclusive create
(O_CREATE|O_EXCL) and increment the suffix on "already exists" so two concurrent
uploads can't race onto the same name.
- The chosen relative path is stored in receipts.file_path as today; the dual
write still also stores the bytes as the DB blob, and serving continues to work
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.