hsa-app/internal/web/scan.go
Jean-Michel Tremblay 8b7252dad4 Initial commit: HSA receipt tracker
Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth
against Authelia, SQLite storage with dual-write (filesystem + DB blob),
mobile-first upload, and DB export.

Adds AI receipt classification: a config.json catalog of people and
categories (seeded into the DB on startup), a prompt builder that derives
name-order/initial variants from the data (with same-surname ambiguity
handling), and an Anthropic tool-use client behind POST /classify. Tests
run against a mock endpoint; a live integration test is env-gated to the
cheapest model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:40:12 -04:00

105 lines
2.8 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"maisym.com/hsa/internal/storage"
)
// classifyResponse is the JSON returned to the upload page to pre-fill the form.
// IDs map the classifier's label suggestions onto the live lookup rows; they are
// null when the classifier returned nothing or the label has no matching row.
type classifyResponse struct {
Amount *string `json:"amount"`
Date *string `json:"date"`
CategoryID *int64 `json:"category_id"`
PersonID *int64 `json:"person_id"`
Category string `json:"category"` // label, for display
Person string `json:"person"` // label, for display
RawName string `json:"raw_name"`
RawDate string `json:"raw_date"`
RawAmount string `json:"raw_amount"`
}
// handleClassify accepts an uploaded receipt image and returns suggested form
// values from the AI classifier. It mutates no state — the user still reviews and
// submits via POST /upload.
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
if s.classifier == nil {
http.Error(w, "classification not configured", http.StatusServiceUnavailable)
return
}
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("receipt")
if err != nil {
http.Error(w, "attach a receipt image or PDF", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "could not read the uploaded file", http.StatusBadRequest)
return
}
mimeType := detectMime(data)
if !allowedMime(mimeType) {
http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType)
return
}
sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType)
if err != nil {
s.serverError(w, "classify receipt", err)
return
}
cats, people, err := s.lookups()
if err != nil {
s.serverError(w, "load lookups", err)
return
}
out := classifyResponse{
Amount: sug.Amount,
Date: sug.Date,
Category: sug.Category,
RawName: sug.RawName,
RawDate: sug.RawDate,
RawAmount: sug.RawAmount,
}
if id, ok := idForLabel(sug.Category, cats); ok {
out.CategoryID = &id
}
if sug.Person != nil {
if id, ok := idForLabel(*sug.Person, people); ok {
out.PersonID = &id
out.Person = *sug.Person
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
// idForLabel finds a lookup row by case-insensitive label match.
func idForLabel(label string, set []storage.Lookup) (int64, bool) {
label = strings.TrimSpace(label)
for _, l := range set {
if strings.EqualFold(l.Label, label) {
return l.ID, true
}
}
return 0, false
}