hsa-app/internal/web/scan.go
Jean-Michel Tremblay 7ba0d5abe7
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Normalize receipt image orientation from EXIF; add changelog (0.0.1)
Bake the EXIF Orientation rotation into uploaded JPEG pixels (and strip
the tag) so receipts are upright in every consumer, not just EXIF-aware
viewers. Acts only when orientation is known (tag 2..8); images with no
tag, tag 1, non-JPEG, or PDFs pass through byte-for-byte. Wired into the
receipt, attachments, and AI-classify paths. Documented as spec item 13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:26:03 -04:00

114 lines
3.2 KiB
Go

package web
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"maisym.com/hsa/internal/classify"
"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"`
Model string `json:"model"` // model that ran, for display
CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown
}
// 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
}
data = normalizeOrientation(data, mimeType) // upright image reads better for the AI
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,
Model: s.classifier.Model,
}
if cents, ok := classify.CostCents(s.classifier.Model, sug.Usage); ok {
out.CostCents = &cents
}
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
}