hsa-app/internal/web/upload.go
Jean-Michel Tremblay 5c21f131fd Add AI auto-fill, tally, recent views, and cheaper-by-default classification
Features (see spec.md v2):
- Wire receipt classification into the upload flow; cheap model (Haiku 4.5)
  is now the default, shown as a footnote with per-scan cost in cents.
- Skip-AI toggle to enter fields by hand.
- Duplicate-transaction warning: live check on date+amount, gated submit.
- Tally tab: person x year totals with margins and grand total.
- Recent uploads / recent receipts tabs with paging and file serving.
- People reconcile on startup: merge stray partial names (e.g. "Jude" ->
  "Jude Tremblay"), reassigning receipts; idempotent seeding.
- scripts/build.sh builds the binary; scripts/run.sh builds and runs with .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:04:42 -04:00

266 lines
6.2 KiB
Go

package web
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"maisym.com/hsa/internal/receipt"
"maisym.com/hsa/internal/storage"
)
const dateLayout = "2006-01-02"
type formView struct {
Subject string
Categories []storage.Lookup
People []storage.Lookup
Amount string
Date string
CategoryID string
PersonID string
Errors []string
// ClassifyModel is the model that will read the receipt to pre-fill the form,
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
ClassifyModel string
ClassifyEnabled bool
}
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r.Context())
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
s.renderForm(w, formView{
Subject: sess.Subject,
Categories: cats,
People: people,
Date: time.Now().Format(dateLayout),
})
}
func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
cats, err = s.store.ListCategories()
if err != nil {
return nil, nil, err
}
people, err = s.store.ListPeople()
if err != nil {
return nil, nil, err
}
return cats, people, nil
}
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
v.ClassifyModel = s.cfg.ClassifyModel
v.ClassifyEnabled = s.classifier != nil
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
s.serverError(w, "render form", err)
}
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r.Context())
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(10 << 20); err != nil {
s.renderForm(w, formView{
Subject: sess.Subject, Categories: cats, People: people,
Errors: []string{"Upload too large or malformed."},
})
return
}
amountStr := strings.TrimSpace(r.FormValue("amount"))
dateStr := strings.TrimSpace(r.FormValue("receipt_date"))
categoryStr := strings.TrimSpace(r.FormValue("category_id"))
personStr := strings.TrimSpace(r.FormValue("person_id"))
view := formView{
Subject: sess.Subject, Categories: cats, People: people,
Amount: amountStr, Date: dateStr, CategoryID: categoryStr, PersonID: personStr,
}
var errs []string
amountCents, err := receipt.ParseAmountCents(amountStr)
if err != nil {
errs = append(errs, "Enter a valid amount, e.g. 12.34")
}
receiptDate, err := time.Parse(dateLayout, dateStr)
if err != nil {
errs = append(errs, "Enter a valid date.")
}
categoryID, ok := parseLookupID(categoryStr, cats)
if !ok {
errs = append(errs, "Choose a category.")
}
var personID *int64
if personStr != "" {
pid, ok := parseLookupID(personStr, people)
if !ok {
errs = append(errs, "Choose a valid \"Who\" or leave it blank.")
} else {
personID = &pid
}
}
file, header, err := r.FormFile("receipt")
if err != nil {
errs = append(errs, "Attach a receipt photo or PDF.")
}
var data []byte
if file != nil {
defer file.Close()
data, err = io.ReadAll(file)
if err != nil {
errs = append(errs, "Could not read the uploaded file.")
}
}
mimeType := ""
if len(data) > 0 {
mimeType = detectMime(data)
if !allowedMime(mimeType) {
errs = append(errs, "Only images and PDFs are allowed.")
}
}
if len(errs) > 0 {
view.Errors = errs
s.renderForm(w, view)
return
}
// Dual-write: file on disk (UUID name) + blob in DB.
id := newID()
ext := extFor(header.Filename, mimeType)
relPath := id + ext
if err := s.saveFile(relPath, data); err != nil {
s.serverError(w, "save file", err)
return
}
rec := receipt.Receipt{
ID: id,
UploadedBy: sess.Subject,
UploadedAt: time.Now().UTC(),
ReceiptDate: receiptDate,
AmountCents: amountCents,
CategoryID: categoryID,
PersonID: personID,
FilePath: relPath,
ImageData: data,
FileSizeBytes: int64(len(data)),
OriginalFilename: header.Filename,
MimeType: mimeType,
}
if err := s.store.Insert(rec); err != nil {
s.serverError(w, "insert receipt", err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = confirmPage.ExecuteTemplate(w, "base", map[string]string{
"Category": labelFor(categoryID, cats),
"Who": whoLabel(personID, people),
"Amount": fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100),
"Date": dateStr,
"Filename": header.Filename,
})
}
// parseLookupID parses s as an int64 and confirms it exists in the given set.
func parseLookupID(s string, set []storage.Lookup) (int64, bool) {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, false
}
for _, l := range set {
if l.ID == id {
return id, true
}
}
return 0, false
}
func labelFor(id int64, set []storage.Lookup) string {
for _, l := range set {
if l.ID == id {
return l.Label
}
}
return ""
}
func whoLabel(id *int64, set []storage.Lookup) string {
if id == nil {
return ""
}
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
}
return os.WriteFile(filepath.Join(s.cfg.StorageDir, relPath), data, 0o600)
}
func detectMime(data []byte) string {
n := min(len(data), 512)
ct := http.DetectContentType(data[:n])
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.TrimSpace(ct)
}
func allowedMime(mime string) bool {
return strings.HasPrefix(mime, "image/") || mime == "application/pdf"
}
func extFor(filename, mimeType string) string {
if ext := strings.ToLower(filepath.Ext(filename)); ext != "" && len(ext) <= 5 {
return ext
}
switch mimeType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
case "application/pdf":
return ".pdf"
default:
return ".bin"
}
}
// newID returns a random UUIDv4 string.
func newID() string {
var b [16]byte
_, _ = rand.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}