NextExpense/internal/handlers/file.go
cclohmar 4895b3d608 feat: add KES support + base currency + exchange rate for expense claims
- Added KES and 12+ additional currencies to receipt form
- Events now have base_currency (claim currency) and exchange_rate fields
- Receipts show original amount + auto-computed converted amount
- Converted amounts stored per expense in database
- CSV and PDF reports include both original and converted amounts
- Dashboard shows claim currency per event card
2026-05-30 12:23:31 +00:00

331 lines
11 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 (
"bytes"
"database/sql"
"encoding/csv"
"fmt"
"log"
"net/http"
"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 attachment *email.Attachment
switch format {
case "csv":
attachment, err = generateCSV(event.Name, expenses)
case "pdf":
attachment, 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. Send the report as an email attachment.
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."
if err := h.EmailSender.SendReport(to, subject, body, attachment); 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.
totalOrig := 0.0
totalConv := 0.0
for _, exp := range expenses {
var row []string
if hasConversion {
row = []string{
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{
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)
}
return &email.Attachment{
Filename: "report.csv",
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.
pdf.SetFont("Helvetica", "B", 10)
var headers []string
var colWidths []float64
if hasConversion {
headers = []string{"Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
colWidths = []float64{25, 40, 20, 12, 22, 14, 30}
} else {
headers = []string{"Date", "Merchant", "Amount", "Currency", "Category"}
colWidths = []float64{30, 45, 25, 20, 45}
}
for i, h := range headers {
pdf.Cell(colWidths[i], 8, h)
}
pdf.Ln(8)
// Table data rows.
pdf.SetFont("Helvetica", "", 9)
for _, exp := range expenses {
if hasConversion {
pdf.Cell(colWidths[0], 8, exp.Date)
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[3], 8, exp.Currency)
pdf.Cell(colWidths[4], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
pdf.Cell(colWidths[5], 8, exp.BaseCurrency)
pdf.Cell(colWidths[6], 8, truncateString(exp.Category, 12))
} else {
pdf.Cell(colWidths[0], 8, exp.Date)
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 20))
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[3], 8, exp.Currency)
pdf.Cell(colWidths[4], 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)
}
return &email.Attachment{
Filename: "report.pdf",
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] + "…"
}