- 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
338 lines
11 KiB
Go
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 ""
|
|
}
|