// Package handlers provides HTTP request handlers for ExpenseFlow. // // This file implements expense upload, AI extraction, and save handlers // that drive the core receipt capture workflow using HTMX partial responses. package handlers import ( "database/sql" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/cclohmar/ReceiptNext/internal/ai" "github.com/cclohmar/ReceiptNext/internal/database" "github.com/cclohmar/ReceiptNext/internal/utils" "github.com/go-chi/chi/v5" ) // --------------------------------------------------------------------------- // ExpenseHandler // --------------------------------------------------------------------------- // ExpenseHandler groups HTTP handlers related to expense management. // It depends on a shared *sql.DB handle for database operations. type ExpenseHandler struct { DB *sql.DB } // NewExpenseHandler creates a new ExpenseHandler with the given database handle. func NewExpenseHandler(db *sql.DB) *ExpenseHandler { return &ExpenseHandler{DB: db} } // --------------------------------------------------------------------------- // POST /expenses/upload — UploadReceipt // --------------------------------------------------------------------------- // UploadReceipt handles receipt image upload, AI extraction, and returns // an HTMX fragment with a pre-filled receipt edit form. // // Flow: // 1. Parse multipart form (max 10 MB memory buffer) // 2. Validate file (size ≤ 10 MB, content type JPEG/PNG) // 3. Save to ./storage/{uuid}.{jpg|png} // 4. Call ai.ExtractReceipt for AI-powered data extraction // 5. Render templates/receipt_form.html with pre-filled fields or error banner func (h *ExpenseHandler) 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) http.Error(w, "Failed to parse upload form", http.StatusBadRequest) 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) http.Error(w, "Missing receipt file", http.StatusBadRequest) 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) http.Error(w, "File too large. Maximum size is 10 MB.", http.StatusBadRequest) 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) http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError) return } // 5. Validate content type by inspecting magic bytes. ext := detectImageExtension(fileData) if ext == "" { log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q", time.Now().Format(time.RFC3339), ext) http.Error(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.", http.StatusBadRequest) return } // 6. Generate a UUID-based filename and ensure the storage directory exists. filename := utils.New() + "." + 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) http.Error(w, "Server error", http.StatusInternalServerError) 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) http.Error(w, "Failed to save receipt image", http.StatusInternalServerError) return } // 8. Fetch the event's base currency and exchange rate. eventID := getCurrentEventID(r) var baseCurrency string var exchangeRate float64 if eventID != "" { if event, err := database.GetEventByID(h.DB, eventID); err == nil && event != nil { baseCurrency = event.BaseCurrency exchangeRate = event.ExchangeRate } } if baseCurrency == "" { baseCurrency = "EUR" } if exchangeRate <= 0 { exchangeRate = 1.0 } // 9. Call the Gemini Vision API for AI extraction. receipt, aiErr := ai.ExtractReceipt(storagePath) // 10. Render the receipt_form.html fragment. tmpl, err := template.ParseFiles("templates/receipt_form.html") if err != nil { log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } data := map[string]interface{}{ "ImagePath": storagePath, "AIError": "", "Amount": "", "Currency": "", "Merchant": "", "Category": "", "Date": "", "Description": "", "BaseCurrency": baseCurrency, "ExchangeRate": exchangeRate, "ConvertedAmount": "", } 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["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64) data["Currency"] = receipt.Currency data["Merchant"] = receipt.Merchant data["Category"] = receipt.Category data["Date"] = receipt.Date // Compute converted amount only if currencies differ. if receipt.Currency != "" && receipt.Currency != baseCurrency && receipt.Amount > 0 { converted := receipt.Amount * exchangeRate data["ConvertedAmount"] = strconv.FormatFloat(converted, 'f', 2, 64) } } 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 /expenses — SaveExpense // --------------------------------------------------------------------------- // SaveExpense handles the receipt form submission, saves the expense to the // database, and returns an HTMX multi-target response that replaces both the // receipt form (with a success message) and the expense list. // // Flow: // 1. Read event_id from the current_event_id cookie // 2. Parse and validate form fields (amount, currency, merchant, category, date) // 3. Create the expense record in the database // 4. Fetch the updated expense list // 5. Render HTMX response with success banner + updated expense list func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) { // 1. Get the active event ID from cookie. eventID := getCurrentEventID(r) if eventID == "" { log.Printf("ERROR [%s] handlers: SaveExpense: missing current_event_id cookie", time.Now().Format(time.RFC3339)) http.Error(w, "No active event. Please select an event first.", http.StatusBadRequest) return } // 2. Parse form fields. if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: parse form: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Cannot parse form data", http.StatusBadRequest) return } amountStr := r.FormValue("amount") currency := r.FormValue("currency") merchant := r.FormValue("merchant") category := r.FormValue("category") date := r.FormValue("date") description := r.FormValue("description") imagePath := r.FormValue("image_path") // Read conversion fields (hidden fields from receipt form). baseCurrency := r.FormValue("base_currency") convertedAmountStr := r.FormValue("converted_amount") // 3. Validate required fields. var missing []string if amountStr == "" { missing = append(missing, "amount") } if currency == "" { missing = append(missing, "currency") } if merchant == "" { missing = append(missing, "merchant") } if category == "" { missing = append(missing, "category") } if date == "" { missing = append(missing, "date") } if len(missing) > 0 { log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s", time.Now().Format(time.RFC3339), strings.Join(missing, ", ")) http.Error(w, "Missing required fields: "+strings.Join(missing, ", "), http.StatusBadRequest) return } // 4. Parse amount as float64. amount, err := strconv.ParseFloat(amountStr, 64) if err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: invalid amount %q: %v", time.Now().Format(time.RFC3339), amountStr, err) http.Error(w, "Invalid amount value", http.StatusBadRequest) return } // Parse converted amount (optional). convertedAmount := 0.0 if convertedAmountStr != "" { convertedAmount, _ = strconv.ParseFloat(convertedAmountStr, 64) } if baseCurrency == "" || baseCurrency == currency { baseCurrency = currency convertedAmount = amount } if convertedAmount <= 0 { convertedAmount = amount baseCurrency = currency } // 5. Build and save the expense record. expense := database.Expense{ ID: utils.New(), EventID: eventID, Amount: amount, Currency: currency, ConvertedAmount: convertedAmount, BaseCurrency: baseCurrency, Merchant: merchant, Category: category, Description: description, Date: date, ImagePath: imagePath, } if err := database.CreateExpense(h.DB, expense); err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: create expense: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to save expense", http.StatusInternalServerError) return } // 6. Fetch the updated expense list for this event. expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: fetch expenses: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to retrieve updated expenses", http.StatusInternalServerError) return } // 7. Render the expense_list.html fragment. listTmpl, err := template.ParseFiles("templates/expense_list.html") if err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } var listBuf strings.Builder if err := listTmpl.Execute(&listBuf, map[string]interface{}{ "Expenses": expenses, }); err != nil { log.Printf("ERROR [%s] handlers: SaveExpense: execute list template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } // 8. Return HTMX multi-target response. // - The receipt form is replaced with a success message (hx-swap-oob). // - The expense list is replaced with the updated list (hx-swap-oob). w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Expense saved successfully!
`) fmt.Fprintf(w, `
%s
`, listBuf.String()) } // --------------------------------------------------------------------------- // GET /expenses/{id}/edit — EditExpense // --------------------------------------------------------------------------- // EditExpense returns the receipt form pre-filled with an existing expense's // data, allowing the user to edit and re-save it. func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) { expenseID := chi.URLParam(r, "id") if expenseID == "" { http.Error(w, "Missing expense ID", http.StatusBadRequest) return } expense, err := database.GetExpenseByID(h.DB, expenseID) if err != nil { log.Printf("ERROR [%s] handlers: EditExpense: GetExpenseByID(%s): %v", time.Now().Format(time.RFC3339), expenseID, err) http.Error(w, "Failed to retrieve expense", http.StatusInternalServerError) return } if expense == nil { http.Error(w, "Expense not found", http.StatusNotFound) return } // Verify ownership: the expense's event must belong to the current user. event, err := database.GetEventByID(h.DB, expense.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } tmpl, err := template.ParseFiles("templates/receipt_form.html") if err != nil { log.Printf("ERROR [%s] handlers: EditExpense: parse template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } data := map[string]interface{}{ "ImagePath": expense.ImagePath, "AIError": "", "Amount": strconv.FormatFloat(expense.Amount, 'f', 2, 64), "Currency": expense.Currency, "Merchant": expense.Merchant, "Category": expense.Category, "Date": expense.Date, "Description": expense.Description, "BaseCurrency": expense.BaseCurrency, "ExchangeRate": 0.0, "ConvertedAmount": strconv.FormatFloat(expense.ConvertedAmount, 'f', 2, 64), "EditID": expense.ID, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.Execute(w, data); err != nil { log.Printf("ERROR [%s] handlers: EditExpense: execute template: %v", time.Now().Format(time.RFC3339), err) } } // --------------------------------------------------------------------------- // PUT /expenses/{id} — UpdateExpense // --------------------------------------------------------------------------- // UpdateExpense updates an existing expense record with form data and returns // the updated expense list via HTMX multi-target response. func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { expenseID := chi.URLParam(r, "id") if expenseID == "" { http.Error(w, "Missing expense ID", http.StatusBadRequest) return } if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: UpdateExpense: parse form: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Cannot parse form data", http.StatusBadRequest) return } amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) convertedAmount, _ := strconv.ParseFloat(r.FormValue("converted_amount"), 64) baseCurrency := r.FormValue("base_currency") if baseCurrency == "" { baseCurrency = r.FormValue("currency") convertedAmount = amount } // Fetch the existing expense to preserve the event_id and image_path. existing, err := database.GetExpenseByID(h.DB, expenseID) if err != nil || existing == nil { log.Printf("ERROR [%s] handlers: UpdateExpense: get existing: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Expense not found", http.StatusNotFound) return } // Verify ownership: the expense's event must belong to the current user. event, err := database.GetEventByID(h.DB, existing.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } expense := database.Expense{ ID: expenseID, EventID: existing.EventID, Amount: amount, Currency: r.FormValue("currency"), ConvertedAmount: convertedAmount, BaseCurrency: baseCurrency, Merchant: r.FormValue("merchant"), Category: r.FormValue("category"), Description: r.FormValue("description"), Date: r.FormValue("date"), ImagePath: existing.ImagePath, } if err := database.UpdateExpense(h.DB, expense); err != nil { log.Printf("ERROR [%s] handlers: UpdateExpense: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to update expense", http.StatusInternalServerError) return } // Return updated expense list via HTMX. expenses, err := database.GetExpensesByEvent(h.DB, existing.EventID) if err != nil { log.Printf("ERROR [%s] handlers: UpdateExpense: fetch expenses: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError) return } listTmpl, err := template.ParseFiles("templates/expense_list.html") if err != nil { log.Printf("ERROR [%s] handlers: UpdateExpense: parse template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } var listBuf strings.Builder if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil { log.Printf("ERROR [%s] handlers: UpdateExpense: execute template: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Expense updated successfully!
`) fmt.Fprintf(w, `
%s
`, listBuf.String()) } // --------------------------------------------------------------------------- // Cookie helpers (shared with events.go via package-level access) // --------------------------------------------------------------------------- // getCurrentEventID reads the "current_event_id" cookie from the request. // Returns an empty string if the cookie is not set or cannot be read. func getCurrentEventID(r *http.Request) string { cookie, err := r.Cookie("current_event_id") if err != nil { return "" } return cookie.Value } // setCurrentEventID sets the "current_event_id" cookie on the response. // The cookie has a 24-hour lifetime and is HttpOnly with SameSite=Lax. // This helper is called from events.go when viewing an event. func setCurrentEventID(w http.ResponseWriter, eventID string) { http.SetCookie(w, &http.Cookie{ Name: "current_event_id", Value: eventID, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, // 24 hours }) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // detectImageExtension examines the magic bytes of the provided data to // determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF, // and HEIC/HEIF (common on iPhones). Returns the file extension (without // dot) or an empty string if the format is not recognised. 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 0D 0A 1A 0A 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: 52 49 46 46 .... 57 45 42 50 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: 47 49 46 38 (39 61 or 37 61) 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: 42 4D if data[0] == 0x42 && data[1] == 0x4D { return "bmp" } // TIFF: 49 49 2A 00 or 4D 4D 00 2A 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: 25 50 44 46 (%PDF) if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 { return "pdf" } // HEIC/HEIF/AVIF: .... 66 74 79 70 ... (ftyp box) // The ftyp box starts at offset 4 with brand at offset 8. 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 "" }