NextExpense/internal/handlers/months.go
cclohmar d65c0cd5fa feat: add month hierarchy, AI categories, and UI polish
- Restructure hierarchy: Month → Event → Expense (new months table, FK)
- Add MonthHandler with CRUD, monthly reports, dropdown create form
- Events now scoped under months with extended ownership chain
- AI extraction: 16 specific expense categories (Airfare, Meals, etc.)
- UI: category dropdown, button-consistent cards, centered mobile shell on desktop
- Dashboard/month views show claim totals per card
- Description field now mandatory, forms simplified
- Months sorted by name chronologically (latest first)
2026-07-14 09:29:26 +00:00

758 lines
25 KiB
Go

// Package handlers provides HTTP request handlers for NextExpense.
//
// This file implements month management endpoints including month listing,
// creation, editing, deletion, event viewing within a month, and monthly
// report generation that aggregates all events across a month.
package handlers
import (
"archive/zip"
"bytes"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
)
// ---------------------------------------------------------------------------
// MonthHandler
// ---------------------------------------------------------------------------
// MonthHandler groups HTTP handlers related to month management.
// It depends on a shared *sql.DB handle for database operations and an
// optional *email.Sender for delivering monthly reports via email.
type MonthHandler struct {
DB *sql.DB
EmailSender *email.Sender
}
// NewMonthHandler creates a new MonthHandler with the given database handle.
func NewMonthHandler(db *sql.DB) *MonthHandler {
return &MonthHandler{DB: db}
}
// ---------------------------------------------------------------------------
// GET /dashboard — ListMonths (replaces old EventHandler.Dashboard)
// ---------------------------------------------------------------------------
// ListMonths renders the main dashboard page showing all months belonging
// to the authenticated user, along with the create month form.
func (h *MonthHandler) ListMonths(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ListMonths: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
months, err := database.GetMonthsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: GetMonthsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load months", http.StatusInternalServerError)
return
}
// Sort by month name descending (latest first): "December 2026" before "January 2026".
sort.Slice(months, func(i, j int) bool {
yi, mi := parseMonthName(months[i].Name)
yj, mj := parseMonthName(months[j].Name)
if yi != yj {
return yi > yj
}
return mi > mj
})
// Compute total claim per month.
type MonthWithTotal struct {
Month database.Month
Total float64
}
var items []MonthWithTotal
for _, m := range months {
total, _ := database.GetMonthTotalClaim(h.DB, m.ID)
items = append(items, MonthWithTotal{Month: m, Total: total})
}
tmpl := getTemplate("dashboard.html")
data := map[string]interface{}{
"Months": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /months — CreateMonth
// ---------------------------------------------------------------------------
// CreateMonth handles the creation of a new month for the authenticated user.
func (h *MonthHandler) CreateMonth(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month := strings.TrimSpace(r.FormValue("month"))
year := strings.TrimSpace(r.FormValue("year"))
if month == "" || year == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing month or year", time.Now().Format(time.RFC3339))
http.Error(w, "Month and year are required", http.StatusBadRequest)
return
}
name := month + " " + year
id := utils.NewUUID()
if err := database.CreateMonth(h.DB, id, userID, name); err != nil {
log.Printf("ERROR [%s] handlers: CreateMonth: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /months/{mid} — ViewMonth
// ---------------------------------------------------------------------------
// ViewMonth displays all events under a given month.
func (h *MonthHandler) ViewMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
http.Error(w, "Month not found", http.StatusNotFound)
return
}
if month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: GetEventsByMonth(%s): %v",
time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
// Compute total claim per event.
type EventWithTotal struct {
Event database.Event
Total float64
Currency string
}
var items []EventWithTotal
for _, evt := range events {
total, _ := database.GetEventTotalClaim(h.DB, evt.ID)
items = append(items, EventWithTotal{Event: evt, Total: total, Currency: evt.BaseCurrency})
}
tmpl := getTemplate("month_events.html")
data := map[string]interface{}{
"Month": month,
"Events": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// GET /months/{mid}/edit — EditMonth
// ---------------------------------------------------------------------------
// EditMonth returns an inline edit form fragment for a month.
func (h *MonthHandler) EditMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">Edit Month</h3>
<form hx-put="/months/%s" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Month Name</label>
<input type="text" name="name" value="%s" placeholder="e.g. July 2026">
</div>
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center;"
onclick="document.getElementById('create-form').innerHTML='';document.getElementById('create-form').classList.add('hidden')">Cancel</button>
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
onclick="if(confirm('Delete this month and ALL its events and receipts?')){htmx.trigger('#delete-month-%s','click')}">Delete</button>
<div hx-delete="/months/%s" hx-target="body" hx-push-url="true" id="delete-month-%s" style="display:none"></div>
</div>
</form>
</div>`, month.ID, template.HTMLEscapeString(month.Name), month.ID, month.ID, month.ID)
}
// ---------------------------------------------------------------------------
// PUT /months/{mid} — UpdateMonth
// ---------------------------------------------------------------------------
// UpdateMonth updates a month's name after ownership verification.
func (h *MonthHandler) UpdateMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Month name is required", http.StatusBadRequest)
return
}
if err := database.UpdateMonth(h.DB, monthID, name); err != nil {
log.Printf("ERROR [%s] handlers: UpdateMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to update month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /months/{mid} — DeleteMonth
// ---------------------------------------------------------------------------
// DeleteMonth removes a month and all its events (cascade deletes expenses).
func (h *MonthHandler) DeleteMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteMonth(h.DB, monthID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to delete month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/generate — GenerateMonthlyReport
// ---------------------------------------------------------------------------
// GenerateMonthlyReport aggregates all expenses across all events in a month
// into a single report package (CSV/PDF + all receipt images ZIP), stores
// it with a download token, and returns an HTMX fragment with download link.
func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
renderFileError(w, "Missing month ID.")
return
}
if err := r.ParseForm(); err != nil {
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
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
renderFileError(w, "Month not found.")
return
}
if month.UserID != userID {
renderFileError(w, "You do not have permission to access this month.")
return
}
// Get all events for this month.
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
renderFileError(w, "Failed to retrieve events.")
return
}
if len(events) == 0 {
renderFileError(w, "No events in this month.")
return
}
// Aggregate all expenses across all events.
var allExpenses []database.Expense
for _, evt := range events {
expenses, err := database.GetExpensesByEvent(h.DB, evt.ID)
if err != nil {
continue
}
allExpenses = append(allExpenses, expenses...)
}
if len(allExpenses) == 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 = generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
} else {
reportAtt, err = generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate %s: %v",
time.Now().Format(time.RFC3339), format, err)
renderFileError(w, "Failed to generate report.")
return
}
// Package everything into a single flat ZIP.
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
// Add all receipt images from all events.
imgIdx := 0
for _, evt := range events {
expenses, _ := database.GetExpensesByEvent(h.DB, evt.ID)
for _, 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 {
continue
}
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
imgIdx++
imgName := fmt.Sprintf("receipt-%d%s", imgIdx, 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)
safeMonth := sanitiseFilename(month.Name)
if safeMonth == "" {
safeMonth = "monthly-report"
}
dlName := safeMonth + ".zip"
pkgFilename := token + ".zip"
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: write %s: %v",
time.Now().Format(time.RFC3339), pkgPath, err)
renderFileError(w, "Failed to save report package.")
return
}
// Store token in DB (24h expiry). Use monthID as event_id for token tracking.
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
if err := database.CreateDownloadToken(h.DB, token, monthID, pkgFilename, expiresAt); err != nil {
os.Remove(pkgPath)
renderFileError(w, "Failed to store download token.")
return
}
log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s",
time.Now().Format(time.RFC3339), pkgFilename, monthID)
ext := format
reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(month.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;">Monthly Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s &amp; %d events, %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:</p>
<form hx-post="/months/%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(events), imgIdx,
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/send-link — SendMonthlyDownloadLink
// ---------------------------------------------------------------------------
// SendMonthlyDownloadLink emails a download link for a previously generated
// monthly report package.
func (h *MonthHandler) SendMonthlyDownloadLink(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.
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
}
// For monthly reports, the token's event_id stores the month_id.
// Verify the month belongs to the user.
month, err := database.GetMonthByID(h.DB, dt.EventID)
if err != nil || month == nil || month.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
}
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
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(month.Name)
if safeName == "" {
safeName = "monthly-report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
subject := "Monthly Expense Report: " + month.Name
body := fmt.Sprintf("Monthly expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", month.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendMonthlyDownloadLink: %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))
}
// ---------------------------------------------------------------------------
// Monthly report generation helpers
// ---------------------------------------------------------------------------
// generateMonthlyCSV creates a CSV attachment aggregating expenses across
// all events in a month. Each event is prefixed with a section header.
func generateMonthlyCSV(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("Monthly Expense Report: %s\r\n", monthName))
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
buf.WriteString(metaLine + "\r\n")
}
buf.WriteString("\r\n")
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
var grandTotalOrig, grandTotalConv float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
buf.WriteString(fmt.Sprintf("\r\n--- %s ---\r\n", eventNames[evt.ID]))
buf.WriteString("#,Date,Merchant,Amount,Currency,Category,Description\r\n")
var evtTotal float64
for _, exp := range evtExpenses {
buf.WriteString(fmt.Sprintf("%d,%s,%s,%.2f,%s,%s,%s\r\n",
itemNum, exp.Date, exp.Merchant, exp.Amount, exp.Currency, exp.Category, exp.Description))
evtTotal += exp.Amount
if exp.ConvertedAmount > 0 {
grandTotalConv += exp.ConvertedAmount
} else {
grandTotalConv += exp.Amount
}
itemNum++
}
buf.WriteString(fmt.Sprintf("Event Total,,,,%.2f,,\r\n", evtTotal))
grandTotalOrig += evtTotal
}
buf.WriteString(fmt.Sprintf("\r\nGrand Total,,,,%.2f,,\r\n", grandTotalOrig))
filename := fmt.Sprintf("monthly-%s-report.csv", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: []byte(buf.String()),
}, nil
}
// generateMonthlyPDF creates a PDF attachment aggregating expenses across
// all events in a month, with a section per event.
func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
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(10)
}
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
colWidths := []float64{8, 22, 38, 18, 14, 24, 30}
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Desc."}
marginBottom := 20.0
var grandTotal float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
// Event section header with page break check.
if pdf.GetY() > 260 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "B", 11)
pdf.Cell(0, 8, eventNames[evt.ID])
pdf.Ln(10)
// Column headers.
pdf.SetFont("Helvetica", "B", 9)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
}
pdf.Ln(8)
var evtTotal float64
pdf.SetFont("Helvetica", "", 9)
for _, exp := range evtExpenses {
if pdf.GetY() > 297-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 9)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
}
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 9)
}
pdf.Cell(colWidths[0], 7, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 7, exp.Date)
pdf.Cell(colWidths[2], 7, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 7, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 7, exp.Currency)
pdf.Cell(colWidths[5], 7, truncateString(exp.Category, 12))
pdf.Cell(colWidths[6], 7, truncateString(exp.Description, 18))
pdf.Ln(7)
evtTotal += exp.Amount
itemNum++
}
// Event subtotal with separator.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "Event Total")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtTotal))
pdf.Ln(10)
grandTotal += evtTotal
}
// Grand total.
pdf.SetFont("Helvetica", "B", 11)
pdf.Cell(0, 8, fmt.Sprintf("Grand Total: %.2f", grandTotal))
pdf.Ln(10)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("monthly-%s-report.pdf", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: buf.Bytes(),
}, nil
}
// parseMonthName extracts year and month number from a name like "July 2026".
// Returns (0, 0) if parsing fails.
func parseMonthName(name string) (year, month int) {
parts := strings.Fields(name)
if len(parts) < 2 {
return 0, 0
}
months := map[string]int{
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
m, ok := months[strings.ToLower(parts[0])]
if !ok {
return 0, 0
}
y, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0
}
return y, m
}