- 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>
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
// Package receipt holds the receipt domain model and pure validation logic.
|
|
package receipt
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Receipt is a stored receipt record. Category and Who are referenced by id into
|
|
// the categories/people lookup tables (PersonID is nil when "Who" is unset).
|
|
type Receipt struct {
|
|
ID string
|
|
UploadedBy string
|
|
UploadedAt time.Time
|
|
ReceiptDate time.Time
|
|
AmountCents int64
|
|
CategoryID int64
|
|
PersonID *int64
|
|
FilePath string
|
|
ImageData []byte
|
|
FileSizeBytes int64
|
|
OriginalFilename string
|
|
MimeType string
|
|
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,
|
|
// zero, and negative values.
|
|
func ParseAmountCents(s string) (int64, error) {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(s, "$")
|
|
s = strings.ReplaceAll(s, ",", "")
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty amount")
|
|
}
|
|
if strings.HasPrefix(s, "-") {
|
|
return 0, fmt.Errorf("amount must be positive")
|
|
}
|
|
|
|
whole, frac, hasDot := strings.Cut(s, ".")
|
|
if hasDot && strings.Contains(frac, ".") {
|
|
return 0, fmt.Errorf("invalid amount %q", s)
|
|
}
|
|
if whole == "" && frac == "" {
|
|
return 0, fmt.Errorf("invalid amount %q", s)
|
|
}
|
|
|
|
// Normalise the fractional part to exactly two digits.
|
|
switch len(frac) {
|
|
case 0:
|
|
frac = "00"
|
|
case 1:
|
|
frac = frac + "0"
|
|
case 2:
|
|
// ok
|
|
default:
|
|
return 0, fmt.Errorf("amount has more than two decimal places: %q", s)
|
|
}
|
|
|
|
var cents int64
|
|
for _, r := range whole + frac {
|
|
if r < '0' || r > '9' {
|
|
return 0, fmt.Errorf("invalid amount %q", s)
|
|
}
|
|
cents = cents*10 + int64(r-'0')
|
|
}
|
|
if cents <= 0 {
|
|
return 0, fmt.Errorf("amount must be greater than zero")
|
|
}
|
|
return cents, nil
|
|
}
|