inboxer/internal/handlers/expenses.go
cclohmar ca970104ee chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
2026-05-29 19:43:30 +00:00

338 lines
11 KiB
Go

// 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, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense saved successfully!</div></div>`)
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, 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 whether it is a JPEG or PNG image. Returns "jpg", "png", or
// an empty string if the format is not recognised.
func detectImageExtension(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG magic: 0xFF 0xD8 0xFF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg"
}
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "png"
}
return ""
}