NextExpense/internal/handlers/file.go
cclohmar 517e95adc2 chore: rebrand ReceiptNext to NextExpense
- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense
- Update all import paths across 7 Go source files
- Update templates (titles, headings, branding)
- Update static files (manifest.json, sw.js, CSS)
- Update config (Makefile, install.sh, .env.example)
- Update README with new name and URLs
- Rename service file receiptnext.service -> nextexpense.service
- Update install paths, service names, log paths in install.sh
2026-06-21 18:39:48 +00:00

896 lines
30 KiB
Go

// Package handlers implements HTTP request handlers for NextExpense.
//
// 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"
"crypto/rand"
"database/sql"
"encoding/csv"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/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))
renderFileError(w, "Missing event ID.")
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)
renderFileError(w, "Cannot parse form data.")
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))
renderFileError(w, "Email address is required.")
return
}
if format != "csv" && format != "pdf" {
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
time.Now().Format(time.RFC3339), format)
renderFileError(w, "Format must be 'csv' or 'pdf'.")
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))
renderFileError(w, "Session expired. Please log in again.")
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)
renderFileError(w, "Failed to retrieve event. Please try again.")
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID)
renderFileError(w, "Event not found.")
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)
renderFileError(w, "You do not have permission to file this event.")
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)
renderFileError(w, "Failed to retrieve expenses. Please try again.")
return
}
// Fetch user info for report personalisation.
reportUser, _ := database.GetUserByID(h.DB, userID)
userName := ""
userDept := ""
if reportUser != nil {
userName = reportUser.Name
userDept = reportUser.Department
}
// 5. Generate the report in the requested format.
var reportAttachment *email.Attachment
switch format {
case "csv":
reportAttachment, err = generateCSV(event.Name, expenses, userName, userDept)
case "pdf":
reportAttachment, err = generatePDF(event.Name, expenses, userName, userDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), format, err)
renderFileError(w, "Failed to generate report. Please try again.")
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))
renderFileError(w, "SMTP not configured. Please contact the administrator.")
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)
renderFileError(w, "Failed to send report: "+err.Error())
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)
renderFileError(w, "Report sent but failed to close the event. Please try again.")
return
}
// 8. Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /events/{id}/generate — GenerateReport
// ---------------------------------------------------------------------------
// GenerateReport creates a report package (CSV/PDF + receipt images ZIP),
// stores it on disk with a crypto-random download token, and returns an
// HTMX fragment with download and email-link options. The event is NOT
// closed — the user can add more receipts and regenerate.
func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing event ID.")
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: parse form: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Cannot parse form data.")
return
}
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
if format != "csv" && format != "pdf" {
format = "pdf"
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
renderFileError(w, "Event not found.")
return
}
if event.UserID != userID {
renderFileError(w, "You do not have permission to access this event.")
return
}
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
renderFileError(w, "Failed to retrieve expenses.")
return
}
if len(expenses) == 0 {
renderFileError(w, "No expenses to include in the report.")
return
}
// Fetch user info for report personalisation.
repUser, _ := database.GetUserByID(h.DB, userID)
uName := ""
uDept := ""
if repUser != nil {
uName = repUser.Name
uDept = repUser.Department
}
// Generate the report.
var reportAtt *email.Attachment
if format == "csv" {
reportAtt, err = generateCSV(event.Name, expenses, uName, uDept)
} else {
reportAtt, err = generatePDF(event.Name, expenses, uName, uDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate %s: %v",
time.Now().Format(time.RFC3339), format, err)
renderFileError(w, "Failed to generate report.")
return
}
// Package everything into a single flat ZIP (report + receipt images).
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
// Add report file.
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
// Add receipt images directly (not nested).
for i, exp := range expenses {
if exp.ImagePath == "" {
continue
}
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
data, err := os.ReadFile(safePath)
if err != nil {
log.Printf("WARN [%s] handlers: GenerateReport: reading %q: %v",
time.Now().Format(time.RFC3339), safePath, err)
continue
}
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
imgName := fmt.Sprintf("receipt-%d%s", i+1, ext)
addToZip(pkg, imgName, data)
}
if err := pkg.Close(); err != nil {
renderFileError(w, "Failed to create package.")
return
}
// Save to postbox directory.
os.MkdirAll("storage/postbox", 0755)
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
renderFileError(w, "Failed to generate download token.")
return
}
token := hex.EncodeToString(tokenBytes)
// Use event name as the download filename (GUID only in storage path).
safeEvent := sanitiseFilename(event.Name)
if safeEvent == "" {
safeEvent = "report"
}
dlName := safeEvent + ".zip"
pkgFilename := token + ".zip" // storage filename is always the GUID
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: write %s: %v",
time.Now().Format(time.RFC3339), pkgPath, err)
renderFileError(w, "Failed to save report package.")
return
}
// Store token in DB (24h expiry).
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
if err := database.CreateDownloadToken(h.DB, token, eventID, pkgFilename, expiresAt); err != nil {
os.Remove(pkgPath)
renderFileError(w, "Failed to store download token.")
return
}
log.Printf("INFO [%s] handlers: GenerateReport: package %s created for event %s",
time.Now().Format(time.RFC3339), pkgFilename, eventID)
// Render the download/send fragment.
ext := format
reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(event.Name), ext)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s &amp; %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download>⬇ Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
<form hx-post="/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
template.HTMLEscapeString(reportName), len(expenses),
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /events/{id}/send-link — SendDownloadLink
// ---------------------------------------------------------------------------
// SendDownloadLink emails a download link for a previously generated report
// package to the specified recipient. Returns an HTMX fragment with success
// or error feedback.
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists and belongs to user's event.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
event, err := database.GetEventByID(h.DB, dt.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
// Build the download URL using the request's Host header (most reliable),
// falling back to BASE_URL env var.
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
// Parse scheme and host from BASE_URL as fallback.
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(event.Name)
if safeName == "" {
safeName = "report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
subject := "Expense report: " + event.Name
body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// GET /dl/{token} — ServeDownload
// ---------------------------------------------------------------------------
// ServeDownload streams a previously generated report package to the client.
// Access is controlled via the crypto-random token in the URL — no login
// required. The token is valid for 24 hours from creation.
// The optional filename suffix in the URL (e.g. /dl/{token}/Lagos-report.zip)
// is used for the Content-Disposition header but does not affect access control.
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
http.NotFound(w, r)
return
}
// Check expiry.
expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
http.NotFound(w, r)
return
}
pkgPath := filepath.Join("storage", "postbox", dt.Filename)
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Mark as accessed.
database.MarkDownloadTokenAccessed(h.DB, token)
// Build a friendly download filename from the URL suffix, falling back to the token.
dlName := dt.Filename
if name := chi.URLParam(r, "name"); name != "" {
dlName = filepath.Base(name) // prevent path traversal in the suffix
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
http.ServeFile(w, r, pkgPath)
}
// ---------------------------------------------------------------------------
// StartDownloadCleanup
// ---------------------------------------------------------------------------
// StartDownloadCleanup runs a background goroutine that periodically deletes
// expired download tokens and their associated files from disk.
func (h *FileHandler) StartDownloadCleanup() {
go func() {
for {
time.Sleep(1 * time.Hour)
filenames, err := database.DeleteExpiredDownloadTokens(h.DB)
if err != nil {
log.Printf("ERROR [%s] handlers: download cleanup: %v",
time.Now().Format(time.RFC3339), err)
continue
}
for _, fn := range filenames {
path := filepath.Join("storage", "postbox", fn)
if err := os.Remove(path); err != nil {
log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v",
time.Now().Format(time.RFC3339), path, err)
}
}
if len(filenames) > 0 {
log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages",
time.Now().Format(time.RFC3339), len(filenames))
}
}
}()
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// addToZip adds a file to a zip.Writer. Errors are logged but not returned
// since a missing image in the ZIP is non-fatal — the report is the priority.
func addToZip(zw *zip.Writer, name string, data []byte) {
f, err := zw.Create(name)
if err != nil {
log.Printf("WARN [%s] handlers: addToZip: create %q: %v",
time.Now().Format(time.RFC3339), name, err)
return
}
if _, err := f.Write(data); err != nil {
log.Printf("WARN [%s] handlers: addToZip: write %q: %v",
time.Now().Format(time.RFC3339), name, err)
}
}
// ---------------------------------------------------------------------------
// 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, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// Write user metadata row (prepared by).
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
writer.Write([]string{metaLine})
writer.Write([]string{""}) // blank separator
}
// 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 {
baseCur := ""
for _, exp := range expenses {
if exp.BaseCurrency != "" {
baseCur = exp.BaseCurrency
break
}
}
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), baseCur, "", ""})
} 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, userName, userDept string) (*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(8)
// User info block.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(10)
}
// 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 and automatic page breaks.
pdf.SetFont("Helvetica", "", 9)
marginBottom := 20.0 // mm margin from bottom before page break
var totalOrig, totalConv float64
for i, exp := range expenses {
// Check if we need a page break (A4 = 297mm height).
if pdf.GetY() > 297-marginBottom {
pdf.AddPage()
// Re-draw header row on new page.
pdf.SetFont("Helvetica", "B", 9)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
}
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 9)
}
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))
totalOrig += exp.Amount
totalConv += exp.ConvertedAmount
} 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))
totalOrig += exp.Amount
}
pdf.Ln(8)
}
// Totals row with a separator line.
pdf.SetDrawColor(71, 85, 105) // #475569 border colour
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
if hasConversion {
// Determine the base/claim currency from the first expense that has one.
baseCur := ""
for _, exp := range expenses {
if exp.BaseCurrency != "" {
baseCur = exp.BaseCurrency
break
}
}
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "TOTAL")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", totalConv))
pdf.Cell(colWidths[6], 8, baseCur)
pdf.Cell(colWidths[7], 8, "")
} else {
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "TOTAL")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 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
}
// Normalize path (strip legacy "storage/" prefix if present),
// then construct the full path safely within the storage directory.
// filepath.Base prevents path traversal by extracting only the filename.
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
// Read the image file from disk.
data, err := os.ReadFile(safePath)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
time.Now().Format(time.RFC3339), safePath, 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
}
// renderFileError writes an HTMX-compatible error fragment targeted at the
// #submit-error container on the event expenses page. Using a 200 status
// ensures HTMX always swaps the content (HTMX skips 4xx/5xx by default).
func renderFileError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Retarget", "#submit-error")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="submit-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
template.HTMLEscapeString(message))
}
// 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), "-")
}