- Two upload buttons: Camera (capture) and Upload (gallery/PDF) - PDF receipts from Uber/email now accepted and processed by Gemini Vision - PDF detection via %PDF magic bytes in both handler and AI module - Descriptions updated to reflect broader file support
436 lines
14 KiB
Go
436 lines
14 KiB
Go
// Package handlers implements HTTP request handlers for ExpenseFlow.
|
|
//
|
|
// This file implements the event filing workflow — generating CSV or PDF
|
|
// expense reports and emailing them as attachments to a specified recipient.
|
|
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jung-kurt/gofpdf"
|
|
|
|
"github.com/expenseflow/internal/database"
|
|
"github.com/expenseflow/internal/email"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FileHandler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// FileHandler handles the event filing workflow: generating expense reports
|
|
// in CSV or PDF format and emailing them to a specified address.
|
|
// It depends on a shared *sql.DB handle for database access and an
|
|
// *email.Sender for delivering the report as an email attachment.
|
|
type FileHandler struct {
|
|
DB *sql.DB
|
|
EmailSender *email.Sender
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /events/{id}/file — FileEvent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// FileEvent generates an expense report (CSV or PDF) for a given event and
|
|
// emails it as an attachment to the specified recipient. On success the
|
|
// event status is updated to "closed" and the client is redirected to the
|
|
// dashboard via the HX-Redirect header.
|
|
//
|
|
// Flow:
|
|
// 1. Extract event ID from the URL via chi.URLParam
|
|
// 2. Parse the form for target email and report format
|
|
// 3. Verify the authenticated user owns this event
|
|
// 4. Fetch all expenses for the event from the database
|
|
// 5. Generate the report in the requested format (CSV or PDF)
|
|
// 6. Send the report as an email attachment
|
|
// 7. Update the event status to "closed"
|
|
// 8. Return an HX-Redirect header pointing to /dashboard
|
|
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Get event ID from the URL path parameter.
|
|
eventID := chi.URLParam(r, "id")
|
|
if eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 2. Parse form fields.
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
to := r.FormValue("email")
|
|
format := r.FormValue("format")
|
|
|
|
if to == "" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Email address is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if format != "csv" && format != "pdf" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
|
|
time.Now().Format(time.RFC3339), format)
|
|
http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 3. Verify the authenticated user owns this event.
|
|
userID := getUserID(r)
|
|
if userID == "" {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
event, err := database.GetEventByID(h.DB, eventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if event == nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
|
|
time.Now().Format(time.RFC3339), eventID)
|
|
http.Error(w, "Event not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if event.UserID != userID {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
|
|
time.Now().Format(time.RFC3339), userID, eventID)
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// 4. Fetch all expenses for the event.
|
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 5. Generate the report in the requested format.
|
|
var reportAttachment *email.Attachment
|
|
switch format {
|
|
case "csv":
|
|
reportAttachment, err = generateCSV(event.Name, expenses)
|
|
case "pdf":
|
|
reportAttachment, err = generatePDF(event.Name, expenses)
|
|
}
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
|
|
time.Now().Format(time.RFC3339), format, err)
|
|
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 6. Create a ZIP of all receipt images.
|
|
zipAttachment, zipErr := createReceiptZip(event.Name, expenses)
|
|
|
|
// 7. Build the list of attachments (report + ZIP if available).
|
|
attachments := []*email.Attachment{reportAttachment}
|
|
if zipErr == nil && zipAttachment != nil {
|
|
attachments = append(attachments, zipAttachment)
|
|
} else if zipErr != nil {
|
|
log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v",
|
|
time.Now().Format(time.RFC3339), zipErr)
|
|
}
|
|
|
|
// 8. Send the email with all attachments.
|
|
if h.EmailSender == nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "SMTP not configured. Please set SMTP environment variables.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
subject := "Expense report for event " + event.Name
|
|
body := "Please find attached the expense report and receipt images."
|
|
if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
|
|
time.Now().Format(time.RFC3339), to, err)
|
|
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 7. Update the event status to "closed".
|
|
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
|
|
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
|
|
time.Now().Format(time.RFC3339), eventID, err)
|
|
http.Error(w, "Failed to close event", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 8. Redirect to the dashboard via HTMX.
|
|
w.Header().Set("HX-Redirect", "/dashboard")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Report generation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// generateCSV creates a CSV attachment from the provided expenses.
|
|
// The CSV includes a header row and one data row per expense.
|
|
// If the expenses use a different currency than the base currency, both
|
|
// original and converted amounts are included.
|
|
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
|
var buf bytes.Buffer
|
|
writer := csv.NewWriter(&buf)
|
|
|
|
// Determine if we need conversion columns.
|
|
hasConversion := false
|
|
for _, exp := range expenses {
|
|
if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency {
|
|
hasConversion = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Write header row.
|
|
var header []string
|
|
if hasConversion {
|
|
header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"}
|
|
} else {
|
|
header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"}
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return nil, fmt.Errorf("write CSV header: %w", err)
|
|
}
|
|
|
|
// Write one data row per expense with item number.
|
|
totalOrig := 0.0
|
|
totalConv := 0.0
|
|
for i, exp := range expenses {
|
|
itemNum := i + 1
|
|
var row []string
|
|
if hasConversion {
|
|
row = []string{
|
|
fmt.Sprintf("%d", itemNum),
|
|
exp.Date,
|
|
exp.Merchant,
|
|
fmt.Sprintf("%.2f", exp.Amount),
|
|
exp.Currency,
|
|
fmt.Sprintf("%.2f", exp.ConvertedAmount),
|
|
exp.BaseCurrency,
|
|
exp.Category,
|
|
exp.Description,
|
|
}
|
|
} else {
|
|
row = []string{
|
|
fmt.Sprintf("%d", itemNum),
|
|
exp.Date,
|
|
exp.Merchant,
|
|
fmt.Sprintf("%.2f", exp.Amount),
|
|
exp.Currency,
|
|
exp.Category,
|
|
exp.Description,
|
|
}
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return nil, fmt.Errorf("write CSV row: %w", err)
|
|
}
|
|
totalOrig += exp.Amount
|
|
totalConv += exp.ConvertedAmount
|
|
}
|
|
|
|
// Write totals row.
|
|
if hasConversion {
|
|
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""})
|
|
} else {
|
|
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""})
|
|
}
|
|
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return nil, fmt.Errorf("CSV writer flush: %w", err)
|
|
}
|
|
|
|
filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName))
|
|
return &email.Attachment{
|
|
Filename: filename,
|
|
Content: buf.Bytes(),
|
|
}, nil
|
|
}
|
|
|
|
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
|
|
// The PDF contains a title row, a header row, and one data row per expense.
|
|
// If the expenses use a different currency than the base currency, both
|
|
// original and converted amounts are included.
|
|
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
|
pdf.AddPage()
|
|
|
|
// Title: "Expense Report: <event name>"
|
|
pdf.SetFont("Helvetica", "B", 16)
|
|
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
|
pdf.Ln(15)
|
|
|
|
// Determine if we need conversion columns.
|
|
hasConversion := false
|
|
for _, exp := range expenses {
|
|
if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency {
|
|
hasConversion = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Table header row with item number.
|
|
pdf.SetFont("Helvetica", "B", 10)
|
|
var headers []string
|
|
var colWidths []float64
|
|
if hasConversion {
|
|
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
|
|
colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30}
|
|
} else {
|
|
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"}
|
|
colWidths = []float64{10, 28, 48, 22, 18, 40}
|
|
}
|
|
for i, h := range headers {
|
|
pdf.Cell(colWidths[i], 8, h)
|
|
}
|
|
pdf.Ln(8)
|
|
|
|
// Table data rows with item numbers.
|
|
pdf.SetFont("Helvetica", "", 9)
|
|
for i, exp := range expenses {
|
|
itemNum := i + 1
|
|
if hasConversion {
|
|
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
|
|
pdf.Cell(colWidths[1], 8, exp.Date)
|
|
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18))
|
|
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
|
|
pdf.Cell(colWidths[4], 8, exp.Currency)
|
|
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
|
|
pdf.Cell(colWidths[6], 8, exp.BaseCurrency)
|
|
pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12))
|
|
} else {
|
|
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
|
|
pdf.Cell(colWidths[1], 8, exp.Date)
|
|
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20))
|
|
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
|
|
pdf.Cell(colWidths[4], 8, exp.Currency)
|
|
pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20))
|
|
}
|
|
pdf.Ln(8)
|
|
}
|
|
|
|
// Write the PDF document to a memory buffer.
|
|
var buf bytes.Buffer
|
|
if err := pdf.Output(&buf); err != nil {
|
|
return nil, fmt.Errorf("PDF output: %w", err)
|
|
}
|
|
|
|
filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName))
|
|
return &email.Attachment{
|
|
Filename: filename,
|
|
Content: buf.Bytes(),
|
|
}, nil
|
|
}
|
|
|
|
// createReceiptZip creates a ZIP archive containing all receipt images from the
|
|
// given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP.
|
|
// Returns nil if there are no expenses with images, or if all image files are
|
|
// missing from disk.
|
|
func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
|
safeName := sanitiseFilename(eventName)
|
|
if safeName == "" {
|
|
safeName = "event"
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
added := 0
|
|
|
|
for i, exp := range expenses {
|
|
if exp.ImagePath == "" {
|
|
continue
|
|
}
|
|
|
|
// Read the image file from disk.
|
|
data, err := os.ReadFile(exp.ImagePath)
|
|
if err != nil {
|
|
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
|
|
time.Now().Format(time.RFC3339), exp.ImagePath, err)
|
|
continue
|
|
}
|
|
|
|
// Determine file extension from the image path.
|
|
ext := filepath.Ext(exp.ImagePath)
|
|
if ext == "" {
|
|
ext = ".jpg"
|
|
}
|
|
|
|
filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext)
|
|
f, err := zw.Create(filename)
|
|
if err != nil {
|
|
log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v",
|
|
time.Now().Format(time.RFC3339), filename, err)
|
|
continue
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v",
|
|
time.Now().Format(time.RFC3339), filename, err)
|
|
continue
|
|
}
|
|
added++
|
|
}
|
|
|
|
if err := zw.Close(); err != nil {
|
|
return nil, fmt.Errorf("closing zip: %w", err)
|
|
}
|
|
|
|
if added == 0 {
|
|
log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q",
|
|
time.Now().Format(time.RFC3339), eventName)
|
|
return nil, nil
|
|
}
|
|
|
|
return &email.Attachment{
|
|
Filename: fmt.Sprintf("expense-%s-images.zip", safeName),
|
|
Content: buf.Bytes(),
|
|
}, nil
|
|
}
|
|
|
|
// truncateString truncates a string to the given maximum length, appending "…"
|
|
// if the string was shortened.
|
|
func truncateString(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen-1] + "…"
|
|
}
|
|
|
|
// sanitiseFilename converts a string into a safe filename (alphanumerics,
|
|
// hyphens, underscores only — no spaces or special characters).
|
|
func sanitiseFilename(s string) string {
|
|
var result []rune
|
|
for _, r := range s {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
result = append(result, r)
|
|
} else if r == ' ' || r == '.' {
|
|
result = append(result, '-')
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return "expenses"
|
|
}
|
|
return strings.Trim(string(result), "-")
|
|
}
|