- 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)
758 lines
25 KiB
Go
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 & %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
|
|
}
|