NextExpense/internal/handlers/file.go

876 lines
29 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
// month view via the HX-Redirect header.
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
// 1. Get month and event IDs from the URL path parameters.
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing 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")
if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339))
renderFileError(w, "Email address is required.")
return
}
// 3. Verify the authenticated user owns this event (via month).
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
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to file this event.")
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.MonthID != monthID || 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 both CSV and PDF reports.
csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate CSV report. Please try again.")
return
}
pdfAttachment, err := generatePDF(event.Name, expenses, userName, userDept)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate PDF 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 (CSV + PDF + ZIP if available).
attachments := []*email.Attachment{csvAttachment, pdfAttachment}
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 := fmt.Sprintf("%s | Expense report for event %s", userName, event.Name)
if userName == "" {
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 month view via HTMX.
w.Header().Set("HX-Redirect", "/months/"+monthID)
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) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: GenerateReport: missing ID",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing 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
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to access this event.")
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
renderFileError(w, "Event not found.")
return
}
if event.MonthID != monthID || 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 both CSV and PDF reports.
csvAtt, err := generateCSV(event.Name, expenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate CSV report.")
return
}
pdfAtt, err := generatePDF(event.Name, expenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate PDF report.")
return
}
// Package everything into a single flat ZIP (report + receipt images).
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
// Add both report files.
addToZip(pkg, csvAtt.Filename, csvAtt.Content)
addToZip(pkg, pdfAtt.Filename, pdfAtt.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.
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;">CSV + PDF report &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="/months/%s/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>`,
len(expenses),
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink
// ---------------------------------------------------------------------------
// SendDownloadLink emails a download link for a previously generated report
// package to the specified recipient.
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
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) || event.MonthID != monthID {
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)
// Fetch user name for the subject line.
repUser, _ := database.GetUserByID(h.DB, getUserID(r))
userName := ""
if repUser != nil {
userName = repUser.Name
}
subject := fmt.Sprintf("%s | Expense report: %s", userName, event.Name)
if userName == "" {
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.
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{""})
}
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
writer.Write([]string{fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur)})
writer.Write([]string{""})
// Write header row with both local and claim columns.
header := []string{"#", "Date", "Merchant", "Local Amt", "Currency", "Claim Amt", "Claim Curr", "Category", "Description"}
writer.Write(header)
// Write data rows.
var tableLocal, tableClaim float64
for i, exp := range expenses {
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
row := []string{
fmt.Sprintf("%d", i+1),
exp.Date, exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount), exp.Currency,
fmt.Sprintf("%.2f", claimAmt), claimCur,
exp.Category, exp.Description,
}
writer.Write(row)
tableLocal += exp.Amount
tableClaim += claimAmt
}
// Write totals row.
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", tableLocal), "", fmt.Sprintf("%.2f", tableClaim), "", "", ""})
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("L", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Expense Report: "+eventName)
pdf.Ln(8)
// User info.
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(6)
}
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur))
pdf.Ln(12)
// Table header.
pdf.SetFont("Helvetica", "B", 8)
headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"}
colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100}
for i, h := range headers {
pdf.Cell(colWidths[i], 7, h)
}
pdf.Ln(7)
// Table data rows.
pdf.SetFont("Helvetica", "", 8)
marginBottom := 18.0
var totalLocal, tableClaim float64
for i, exp := range expenses {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
// Compute claim amount (auto-calc if not set).
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
itemNum := i + 1
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt))
pdf.Cell(colWidths[6], 6, claimCur)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
pdf.Ln(6)
totalLocal += exp.Amount
tableClaim += claimAmt
}
// Totals row in claim currency.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
claimTotalCur := ""
for _, exp := range expenses {
if exp.BaseCurrency != "" {
claimTotalCur = exp.BaseCurrency
break
}
}
if claimTotalCur == "" {
claimTotalCur = expenses[0].Currency
}
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "TOTAL")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalLocal))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", tableClaim))
pdf.Cell(colWidths[6], 8, claimTotalCur)
pdf.Cell(colWidths[7], 8, "")
pdf.Cell(colWidths[8], 8, "")
// Write to 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), "-")
}