From e723e7989d65fdbae52ed9c063971c405dac0572 Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Thu, 18 Jun 2026 22:10:41 -0400 Subject: [PATCH] Store receipt files under year/MM_DD_dollars.cents naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat UUID filenames with a browsable, dated layout (spec.md item 9): //_
_., 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 --- internal/web/upload.go | 50 +++++++++++++++++++++++++++++++------ internal/web/upload_test.go | 43 +++++++++++++++++++++++++++---- spec.md | 31 ++++++++++++++++++++++- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/internal/web/upload.go b/internal/web/upload.go index 3b551ba..cfff11d 100644 --- a/internal/web/upload.go +++ b/internal/web/upload.go @@ -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 +// +// //_
_. +// +// 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 { diff --git a/internal/web/upload_test.go b/internal/web/upload_test.go index 493641d..c5b8a1a 100644 --- a/internal/web/upload_test.go +++ b/internal/web/upload_test.go @@ -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) } } diff --git a/spec.md b/spec.md index 5d289d9..3000335 100644 --- a/spec.md +++ b/spec.md @@ -211,4 +211,33 @@ 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.) \ No newline at end of file +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: + + //_
_.. + + - 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. \ No newline at end of file