// Package handlers provides HTTP request handlers for NextReceipt. // // This file implements purchase upload, AI extraction, and save handlers // using HTMX partial responses. package handlers import ( "bytes" "database/sql" "fmt" "html/template" "image" "image/jpeg" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "golang.org/x/image/draw" "github.com/cclohmar/NextReceipt/internal/ai" "github.com/cclohmar/NextReceipt/internal/database" "github.com/cclohmar/NextReceipt/internal/utils" ) // --------------------------------------------------------------------------- // PurchaseHandler // --------------------------------------------------------------------------- // PurchaseHandler groups HTTP handlers related to purchase/receipt management. type PurchaseHandler struct { DB *sql.DB } // NewPurchaseHandler creates a new PurchaseHandler with the given database handle. func NewPurchaseHandler(db *sql.DB) *PurchaseHandler { return &PurchaseHandler{DB: db} } // --------------------------------------------------------------------------- // POST /purchases/upload — UploadReceipt // --------------------------------------------------------------------------- // UploadReceipt handles receipt image upload, AI extraction, and returns // an HTMX fragment with a pre-filled receipt edit form. func (h *PurchaseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { // 1. Parse multipart form with 10 MB max memory. if err := r.ParseMultipartForm(10 << 20); err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v", time.Now().Format(time.RFC3339), err) renderUploadError(w, "Failed to parse upload form.") return } defer r.MultipartForm.RemoveAll() // 2. Get the file from the "receipt" form field. file, header, err := r.FormFile("receipt") if err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v", time.Now().Format(time.RFC3339), err) renderUploadError(w, "Missing receipt file.") return } defer file.Close() // 3. Validate file size (max 10 MB). if header.Size > 10<<20 { log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes", time.Now().Format(time.RFC3339), header.Size) renderUploadError(w, "File too large. Maximum size is 10 MB.") return } // 4. Read the full file data. fileData, err := io.ReadAll(file) if err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v", time.Now().Format(time.RFC3339), err) renderUploadError(w, "Failed to read uploaded file.") return } // 5. Validate content type by inspecting magic bytes. ext := detectImageExtension(fileData) if ext == "" { log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type") renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.") return } // Resize the image (max 2048px, JPEG 85%). resized, resizeErr := resizeImage(fileData) if resizeErr == nil && len(resized) > 0 { fileData = resized if ext != "jpg" && ext != "jpeg" { ext = "jpg" } } // 6. Generate a UUID-based filename and ensure the storage directory exists. filename := utils.NewUUID() + "." + ext storagePath := filepath.Join("storage", filename) if err := os.MkdirAll("storage", 0755); err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v", time.Now().Format(time.RFC3339), err) renderUploadError(w, "Server error. Please try again.") return } // 7. Save the image file to disk. if err := os.WriteFile(storagePath, fileData, 0644); err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v", time.Now().Format(time.RFC3339), err) renderUploadError(w, "Failed to save receipt image. Please try again.") return } // 8. Call the AI API for receipt data extraction. receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename)) // Strip the storage/ prefix so the template can build a proper URL: /storage/{file} storagePath = filename // 9. Render the receipt_form.html fragment. tmpl := getTemplate("receipt_form.html") data := map[string]interface{}{ "ImagePath": storagePath, "AIError": "", "ProductName": "", "Store": "", "Category": "", "Amount": "", "Currency": "", "Date": "", "WarrantyMonths": "0", "ReturnDays": "0", "Notes": "", } if aiErr != nil { data["AIError"] = "Could not read receipt automatically. Please fill in the fields below." log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v", time.Now().Format(time.RFC3339), aiErr) } else if receipt != nil { data["ProductName"] = receipt.ProductName data["Store"] = receipt.Merchant data["Category"] = receipt.Category data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64) data["Currency"] = receipt.Currency data["Date"] = receipt.Date if receipt.WarrantyMonths > 0 { data["WarrantyMonths"] = strconv.Itoa(receipt.WarrantyMonths) } if receipt.ReturnDays > 0 { data["ReturnDays"] = strconv.Itoa(receipt.ReturnDays) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v", time.Now().Format(time.RFC3339), err) } } // --------------------------------------------------------------------------- // POST /purchases — SavePurchase // --------------------------------------------------------------------------- // SavePurchase handles the receipt form submission, saves the purchase to the // database, and returns an HTMX multi-target response. func (h *PurchaseHandler) SavePurchase(w http.ResponseWriter, r *http.Request) { userID := getUserID(r) if userID == "" { http.Error(w, "Session expired. Please log in again.", http.StatusUnauthorized) return } // 1. Parse form fields. if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: SavePurchase: parse form: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Cannot parse form data", http.StatusBadRequest) return } productName := strings.TrimSpace(r.FormValue("product_name")) store := strings.TrimSpace(r.FormValue("store")) category := strings.TrimSpace(r.FormValue("category")) amountStr := strings.TrimSpace(r.FormValue("amount")) currency := strings.TrimSpace(r.FormValue("currency")) date := strings.TrimSpace(r.FormValue("date")) notes := strings.TrimSpace(r.FormValue("notes")) imagePath := strings.TrimSpace(r.FormValue("image_path")) warrantyStr := strings.TrimSpace(r.FormValue("warranty_months")) returnStr := strings.TrimSpace(r.FormValue("return_days")) // 2. Validate required fields. var missing []string if productName == "" { missing = append(missing, "product name") } if store == "" { missing = append(missing, "store") } if category == "" { missing = append(missing, "category") } if amountStr == "" { missing = append(missing, "amount") } if currency == "" { missing = append(missing, "currency") } if date == "" { missing = append(missing, "purchase date") } if len(missing) > 0 { http.Error(w, "Missing required fields: "+strings.Join(missing, ", "), http.StatusBadRequest) return } // 3. Parse numeric fields. amount, err := strconv.ParseFloat(amountStr, 64) if err != nil { http.Error(w, "Invalid amount value", http.StatusBadRequest) return } warrantyMonths := 0 if warrantyStr != "" { warrantyMonths, _ = strconv.Atoi(warrantyStr) } returnDays := 0 if returnStr != "" { returnDays, _ = strconv.Atoi(returnStr) } // 4. Build and save the purchase record. purchase := database.Purchase{ ID: utils.NewUUID(), UserID: userID, ProductName: productName, Store: store, Category: category, Amount: amount, Currency: currency, PurchaseDate: date, WarrantyMonths: warrantyMonths, ReturnDays: returnDays, Notes: notes, ImagePath: imagePath, } if err := database.CreatePurchase(h.DB, purchase); err != nil { log.Printf("ERROR [%s] handlers: SavePurchase: create purchase: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to save purchase", http.StatusInternalServerError) return } // Mark user as onboarded on first purchase. database.MarkUserOnboarded(h.DB, userID) // 5. Fetch the updated purchase list. purchases, err := database.GetPurchasesByUser(h.DB, userID) if err != nil { log.Printf("ERROR [%s] handlers: SavePurchase: fetch purchases: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to retrieve purchases", http.StatusInternalServerError) return } // Normalize ImagePath. for i := range purchases { purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) } // 6. Render the purchase list fragment. listBuf := renderPurchaseList(purchases) // 7. Return HTMX multi-target response. w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Refresh", "false") fmt.Fprintf(w, `
Purchase saved!
`) fmt.Fprintf(w, `
%s
`, listBuf) } // --------------------------------------------------------------------------- // GET /purchases/{id}/edit — EditPurchase // --------------------------------------------------------------------------- // EditPurchase returns the receipt form pre-filled with an existing purchase's data. func (h *PurchaseHandler) EditPurchase(w http.ResponseWriter, r *http.Request) { purchaseID := chi.URLParam(r, "id") if purchaseID == "" { http.Error(w, "Missing purchase ID", http.StatusBadRequest) return } purchase, err := database.GetPurchaseByID(h.DB, purchaseID) if err != nil { log.Printf("ERROR [%s] handlers: EditPurchase: GetPurchaseByID(%s): %v", time.Now().Format(time.RFC3339), purchaseID, err) http.Error(w, "Failed to retrieve purchase", http.StatusInternalServerError) return } if purchase == nil { http.Error(w, "Purchase not found", http.StatusNotFound) return } // Verify ownership. if purchase.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } tmpl := getTemplate("receipt_form.html") data := map[string]interface{}{ "ImagePath": normalizeImagePath(purchase.ImagePath), "AIError": "", "ProductName": purchase.ProductName, "Store": purchase.Store, "Category": purchase.Category, "Amount": strconv.FormatFloat(purchase.Amount, 'f', 2, 64), "Currency": purchase.Currency, "Date": purchase.PurchaseDate, "WarrantyMonths": strconv.Itoa(purchase.WarrantyMonths), "ReturnDays": strconv.Itoa(purchase.ReturnDays), "Notes": purchase.Notes, "EditID": purchase.ID, "ID": purchase.ID, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { log.Printf("ERROR [%s] handlers: EditPurchase: execute template: %v", time.Now().Format(time.RFC3339), err) } } // --------------------------------------------------------------------------- // PUT /purchases/{id} — UpdatePurchase // --------------------------------------------------------------------------- // UpdatePurchase updates an existing purchase record with form data. func (h *PurchaseHandler) UpdatePurchase(w http.ResponseWriter, r *http.Request) { purchaseID := chi.URLParam(r, "id") if purchaseID == "" { http.Error(w, "Missing purchase ID", http.StatusBadRequest) return } if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: UpdatePurchase: parse form: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Cannot parse form data", http.StatusBadRequest) return } existing, err := database.GetPurchaseByID(h.DB, purchaseID) if err != nil || existing == nil { log.Printf("ERROR [%s] handlers: UpdatePurchase: get existing: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Purchase not found", http.StatusNotFound) return } if existing.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) warrantyMonths, _ := strconv.Atoi(r.FormValue("warranty_months")) returnDays, _ := strconv.Atoi(r.FormValue("return_days")) purchase := database.Purchase{ ID: purchaseID, ProductName: strings.TrimSpace(r.FormValue("product_name")), Store: strings.TrimSpace(r.FormValue("store")), Category: strings.TrimSpace(r.FormValue("category")), Amount: amount, Currency: strings.TrimSpace(r.FormValue("currency")), PurchaseDate: strings.TrimSpace(r.FormValue("date")), WarrantyMonths: warrantyMonths, ReturnDays: returnDays, Notes: strings.TrimSpace(r.FormValue("notes")), } if err := database.UpdatePurchase(h.DB, purchase); err != nil { log.Printf("ERROR [%s] handlers: UpdatePurchase: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to update purchase", http.StatusInternalServerError) return } // Return updated purchase list. userID := getUserID(r) purchases, err := database.GetPurchasesByUser(h.DB, userID) if err != nil { log.Printf("ERROR [%s] handlers: UpdatePurchase: fetch purchases: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError) return } for i := range purchases { purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) } listBuf := renderPurchaseList(purchases) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Purchase updated!
`) fmt.Fprintf(w, `
%s
`, listBuf) } // --------------------------------------------------------------------------- // DELETE /purchases/{id} — DeletePurchase // --------------------------------------------------------------------------- // DeletePurchase removes an individual purchase after verifying ownership. func (h *PurchaseHandler) DeletePurchase(w http.ResponseWriter, r *http.Request) { purchaseID := chi.URLParam(r, "id") if purchaseID == "" { http.Error(w, "Missing purchase ID", http.StatusBadRequest) return } existing, err := database.GetPurchaseByID(h.DB, purchaseID) if err != nil || existing == nil { log.Printf("ERROR [%s] handlers: DeletePurchase: get existing(%s): %v", time.Now().Format(time.RFC3339), purchaseID, err) http.Error(w, "Purchase not found", http.StatusNotFound) return } if existing.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } userID := existing.UserID if err := database.DeletePurchase(h.DB, purchaseID); err != nil { log.Printf("ERROR [%s] handlers: DeletePurchase: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to delete purchase", http.StatusInternalServerError) return } // Fetch the updated purchase list. purchases, err := database.GetPurchasesByUser(h.DB, userID) if err != nil { log.Printf("ERROR [%s] handlers: DeletePurchase: fetch purchases: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError) return } for i := range purchases { purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) } listBuf := renderPurchaseList(purchases) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
`) fmt.Fprintf(w, `
%s
`, listBuf) } // --------------------------------------------------------------------------- // Purchase list rendering helper // --------------------------------------------------------------------------- // renderPurchaseList renders the purchase list HTML fragment. func renderPurchaseList(purchases []database.Purchase) string { var buf strings.Builder buf.WriteString(fmt.Sprintf(`

Purchases (%d)

`, len(purchases))) if len(purchases) == 0 { buf.WriteString(`

No purchases yet. Upload a receipt to get started.

`) return buf.String() } buf.WriteString(`
`) for _, p := range purchases { buf.WriteString(renderPurchaseItem(p)) } buf.WriteString(`
`) return buf.String() } // renderPurchaseItem renders a single purchase item HTML. func renderPurchaseItem(p database.Purchase) string { warrantyInfo := "" if p.WarrantyMonths > 0 { warrantyInfo = fmt.Sprintf(`
Warranty: %d months
`, p.WarrantyMonths) } returnInfo := "" if p.ReturnDays > 0 { returnInfo = fmt.Sprintf(`
Return: %d days
`, p.ReturnDays) } notesInfo := "" if p.Notes != "" { notesInfo = fmt.Sprintf(`
%s
`, template.HTMLEscapeString(p.Notes)) } imgBtn := "" imgDiv := "" if p.ImagePath != "" { imgBtn = fmt.Sprintf(``, p.ID) imgDiv = fmt.Sprintf(``, p.ID, template.HTMLEscapeString(p.ImagePath)) } return fmt.Sprintf(`
%s
%s · %s · %s %s
%s%s%s
%s %.2f
%s
%s`, template.HTMLEscapeString(p.ProductName), template.HTMLEscapeString(p.Store), template.HTMLEscapeString(p.Category), template.HTMLEscapeString(p.PurchaseDate), template.HTMLEscapeString(p.Currency), warrantyInfo, returnInfo, notesInfo, template.HTMLEscapeString(p.Currency), p.Amount, imgBtn, template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID), imgDiv) } // --------------------------------------------------------------------------- // Shared helpers (from expenses.go) // --------------------------------------------------------------------------- // renderUploadError writes an HTMX-compatible error fragment. func renderUploadError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(message)) } // detectImageExtension examines the magic bytes of the provided data to // determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF, // HEIC/HEIF, and PDF. func detectImageExtension(data []byte) string { if len(data) < 4 { return "" } // JPEG: FF D8 FF if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { return "jpg" } // PNG: 89 50 4E 47 if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A { return "png" } // WebP if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { return "webp" } // GIF if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 { return "gif" } // BMP if data[0] == 0x42 && data[1] == 0x4D { return "bmp" } // TIFF if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) || (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) { return "tiff" } // PDF if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 { return "pdf" } // HEIC/HEIF/AVIF if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 { brand := string(data[8:12]) switch brand { case "heic", "heix", "hevc", "hevx", "mif1", "msf1": return "heic" case "avif": return "avif" } } return "" } // resizeImage resizes image data to a maximum of 2048 pixels on the longest // side while maintaining aspect ratio. Output is always JPEG at 85% quality. func resizeImage(data []byte) ([]byte, error) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return data, err } bounds := img.Bounds() w, h := bounds.Dx(), bounds.Dy() const maxDim = 2048 if w <= maxDim && h <= maxDim { return data, nil } var newW, newH int if w > h { newW = maxDim newH = h * maxDim / w } else { newH = maxDim newW = w * maxDim / h } dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) var buf bytes.Buffer if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil { return data, err } return buf.Bytes(), nil }