// 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/expenseflow/internal/ai" "github.com/expenseflow/internal/database" "github.com/expenseflow/internal/utils" ) // --------------------------------------------------------------------------- // 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 image type", time.Now().Format(time.RFC3339)) http.Error(w, "Only JPEG and PNG images are supported", 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. Call the DeepSeek Vision API for AI extraction. receipt, aiErr := ai.ExtractReceipt(storagePath) // 9. 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": "", } 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 } 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") // 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 } // 5. Build and save the expense record. expense := database.Expense{ ID: utils.New(), EventID: eventID, Amount: amount, Currency: currency, 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, `