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:
parent
5c21f131fd
commit
e723e7989d
3 changed files with 111 additions and 13 deletions
|
|
@ -148,11 +148,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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()
|
id := newID()
|
||||||
ext := extFor(header.Filename, mimeType)
|
ext := extFor(header.Filename, mimeType)
|
||||||
relPath := id + ext
|
relPath, err := s.saveReceiptFile(receiptDate, amountCents, ext, data)
|
||||||
if err := s.saveFile(relPath, data); err != nil {
|
if err != nil {
|
||||||
s.serverError(w, "save file", err)
|
s.serverError(w, "save file", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -216,11 +216,47 @@ func whoLabel(id *int64, set []storage.Lookup) string {
|
||||||
return labelFor(*id, set)
|
return labelFor(*id, set)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) saveFile(relPath string, data []byte) error {
|
// saveReceiptFile writes the bytes under
|
||||||
if err := os.MkdirAll(s.cfg.StorageDir, 0o700); err != nil {
|
//
|
||||||
return err
|
// <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 {
|
func detectMime(data []byte) string {
|
||||||
|
|
|
||||||
|
|
@ -107,16 +107,49 @@ 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 (dual-write).
|
// File written to disk under the receipt-year subdir with a dated, amount-
|
||||||
entries, err := os.ReadDir(s.cfg.StorageDir)
|
// 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(entries) != 1 {
|
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") {
|
if entries[0].Name() != "06_01_12.34.png" {
|
||||||
t.Errorf("stored file name = %q, want .png suffix", entries[0].Name())
|
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
29
spec.md
|
|
@ -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
|
(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
|
endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is
|
||||||
the only cost surface.)
|
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.
|
||||||
Loading…
Reference in a new issue