// 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, `

Edit Month

`, 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 } 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 both CSV and PDF reports. csvAtt, err := generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept) if err != nil { log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate CSV: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Failed to generate report.") return } pdfAtt, err := generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept) if err != nil { log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate PDF: %v", time.Now().Format(time.RFC3339), 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, csvAtt.Filename, csvAtt.Content) addToZip(pkg, pdfAtt.Filename, pdfAtt.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) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Monthly Report Ready

CSV + PDF & %d events, %d receipt images packaged.

⬇ Download Now

Or send a download link via email:

`, 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, `
Failed to parse form.
`) 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, `
Token and email are required.
`) 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, `
Invalid or expired download token.
`) 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, `
Permission denied.
`) return } if h.EmailSender == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
SMTP not configured.
`) 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) // Fetch user name for the subject line. user, _ := database.GetUserByID(h.DB, getUserID(r)) userName := "" if user != nil { userName = user.Name } subject := fmt.Sprintf("%s | Monthly Expense Report: %s", userName, month.Name) if userName == "" { 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, `
Failed to send: %s
`, template.HTMLEscapeString(err.Error())) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Download link sent to %s.
`, 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 landscape PDF attachment aggregating expenses // across all events in a month, with a section per event. Full text, no truncation. func generateMonthlyPDF(monthName string, events []database.Event, 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, "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(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) // 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 // Landscape A4: 297mm wide, 10mm margins → 277mm usable. colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100} headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"} marginBottom := 18.0 for _, evt := range events { evtExpenses := expensesByEvent[evt.ID] if len(evtExpenses) == 0 { continue } // Event section header. if pdf.GetY() > 180 { pdf.AddPage() } pdf.SetFont("Helvetica", "B", 10) pdf.Cell(0, 8, eventNames[evt.ID]) pdf.Ln(9) // Column headers. pdf.SetFont("Helvetica", "B", 8) for j, h := range headers { pdf.Cell(colWidths[j], 7, h) } pdf.Ln(7) var evtLocal, evtClaim float64 pdf.SetFont("Helvetica", "", 8) for _, exp := range evtExpenses { 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) } claimAmt := exp.ConvertedAmount claimCur := exp.BaseCurrency if claimAmt <= 0 { claimAmt = exp.Amount } if claimCur == "" { claimCur = exp.Currency } 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) evtLocal += exp.Amount evtClaim += claimAmt itemNum++ } // Event subtotal in both local and 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) pdf.Cell(colWidths[0], 8, "") pdf.Cell(colWidths[1], 8, "") pdf.Cell(colWidths[2], 8, fmt.Sprintf("%s Total", eventNames[evt.ID])) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtLocal)) pdf.Cell(colWidths[4], 8, "") pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", evtClaim)) pdf.Cell(colWidths[6], 8, "") pdf.Cell(colWidths[7], 8, "") pdf.Cell(colWidths[8], 8, "") 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 }