diff --git a/internal/ai/gemini.go b/internal/ai/gemini.go index 05e64ac..4b81044 100644 --- a/internal/ai/gemini.go +++ b/internal/ai/gemini.go @@ -88,7 +88,7 @@ func (p geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { payload := geminiRequest{ Contents: []geminiContent{{ Parts: []geminiPart{ - {Text: "Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (Food/Travel/Lodging/Software/Other), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown."}, + {Text: fmt.Sprintf("Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (string, MUST be exactly one of: %s), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown.", categoryPrompt())}, {InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}}, }, }}, diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 4106e3b..a3aa4a2 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -86,7 +86,7 @@ func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { Messages: []openaiMessage{{ Role: "user", Content: []openaiContent{ - {Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences."}, + {Type: "text", Text: fmt.Sprintf("Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, MUST be exactly one of: %s), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences.", categoryPrompt())}, {Type: "image_url", ImageURL: &openaiImage{URL: dataURL}}, }, }}, diff --git a/internal/ai/receipt.go b/internal/ai/receipt.go index 2f9739e..6faaae6 100644 --- a/internal/ai/receipt.go +++ b/internal/ai/receipt.go @@ -23,6 +23,32 @@ type ReceiptData struct { Date string `json:"date"` } +// ValidCategories is the list of allowed expense categories the AI should +// classify receipts into. +var ValidCategories = []string{ + "Airfare", + "Accommodation", + "Meals Self", + "Staff Meal", + "Client Meal", + "Travel - Taxi", + "Travel - Phone", + "Misc Travel", + "Mobile / Office Phone", + "Office Supplies", + "Postage / Couriers", + "Other Expenses", + "Hotel", + "Per Diem", + "Visa Fees", + "Connectivity (internet connections)", +} + +// categoryPrompt returns the comma-separated category list for AI prompts. +func categoryPrompt() string { + return `"Airfare", "Accommodation", "Meals Self", "Staff Meal", "Client Meal", "Travel - Taxi", "Travel - Phone", "Misc Travel", "Mobile / Office Phone", "Office Supplies", "Postage / Couriers", "Other Expenses", "Hotel", "Per Diem", "Visa Fees", "Connectivity (internet connections)"` +} + // Provider is the interface that wraps receipt extraction. // Each provider (Gemini, OpenAI, Ollama) implements this interface. type Provider interface { diff --git a/internal/database/db.go b/internal/database/db.go index ec1eafd..c70f4bf 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -36,10 +36,19 @@ type OTP struct { ExpiresAt string } +// Month represents a row in the months table. +type Month struct { + ID string + UserID string + Name string + CreatedAt string +} + // Event represents a row in the events table. type Event struct { ID string UserID string + MonthID string Name string Status string BaseCurrency string @@ -108,7 +117,7 @@ func Init() (*sql.DB, error) { return DB, nil } -// createTables executes the DDL statements for all four tables. +// createTables executes the DDL statements for all tables. func createTables(db *sql.DB) error { statements := []string{ `CREATE TABLE IF NOT EXISTS users ( @@ -124,15 +133,24 @@ func createTables(db *sql.DB) error { otp_code TEXT NOT NULL, expires_at DATETIME NOT NULL )`, + `CREATE TABLE IF NOT EXISTS months ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )`, `CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + month_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open', base_currency TEXT NOT NULL DEFAULT 'EUR', exchange_rate REAL NOT NULL DEFAULT 1.0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS expenses ( id TEXT PRIMARY KEY, @@ -175,6 +193,7 @@ func migrateTables(db *sql.DB) error { "ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''", "ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''", "ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE events ADD COLUMN month_id TEXT NOT NULL DEFAULT ''", } for _, stmt := range migrations { @@ -287,12 +306,124 @@ func DeleteOTP(db *sql.DB, email string) error { return err } +// --------------------------------------------------------------------------- +// Month queries +// --------------------------------------------------------------------------- + +// CreateMonth inserts a new month row. +func CreateMonth(db *sql.DB, id, userID, name string) error { + _, err := db.Exec( + "INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)", + id, userID, name, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreateMonth(%s, %s, %s): %v", + time.Now().Format(time.RFC3339), id, userID, name, err) + } + return err +} + +// GetMonthsByUser returns all months belonging to a user, ordered by creation date descending. +func GetMonthsByUser(db *sql.DB, userID string) ([]Month, error) { + rows, err := db.Query( + "SELECT id, user_id, name, created_at FROM months WHERE user_id = ? ORDER BY created_at DESC", + userID, + ) + if err != nil { + log.Printf("ERROR [%s] database: GetMonthsByUser(%s): %v", + time.Now().Format(time.RFC3339), userID, err) + return nil, err + } + defer rows.Close() + + var months []Month + for rows.Next() { + var m Month + if err := rows.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil { + log.Printf("ERROR [%s] database: GetMonthsByUser scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + months = append(months, m) + } + return months, rows.Err() +} + +// GetMonthByID returns a single month by ID, or nil if not found. +func GetMonthByID(db *sql.DB, id string) (*Month, error) { + row := db.QueryRow("SELECT id, user_id, name, created_at FROM months WHERE id = ?", id) + m := &Month{} + if err := row.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetMonthByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return m, nil +} + +// UpdateMonth updates the name of an existing month. +func UpdateMonth(db *sql.DB, id, name string) error { + _, err := db.Exec("UPDATE months SET name = ? WHERE id = ?", name, id) + if err != nil { + log.Printf("ERROR [%s] database: UpdateMonth(%s): %v", + time.Now().Format(time.RFC3339), id, err) + } + return err +} + +// DeleteMonth removes a month and all its events (cascade deletes expenses via FK). +func DeleteMonth(db *sql.DB, id string) error { + _, err := db.Exec("DELETE FROM months WHERE id = ?", id) + if err != nil { + log.Printf("ERROR [%s] database: DeleteMonth(%s): %v", + time.Now().Format(time.RFC3339), id, err) + } + return err +} + +// GetMonthTotalClaim returns the sum of all converted_amounts across all +// events and expenses in a given month. Returns 0 if no expenses exist. +func GetMonthTotalClaim(db *sql.DB, monthID string) (float64, error) { + var total sql.NullFloat64 + err := db.QueryRow( + `SELECT COALESCE(SUM(e.converted_amount), 0) + FROM expenses e + JOIN events ev ON e.event_id = ev.id + WHERE ev.month_id = ?`, monthID, + ).Scan(&total) + if err != nil { + return 0, err + } + if total.Valid { + return total.Float64, nil + } + return 0, nil +} + +// GetEventTotalClaim returns the sum of all converted_amounts for a given event. +func GetEventTotalClaim(db *sql.DB, eventID string) (float64, error) { + var total sql.NullFloat64 + err := db.QueryRow( + `SELECT COALESCE(SUM(converted_amount), 0) FROM expenses WHERE event_id = ?`, eventID, + ).Scan(&total) + if err != nil { + return 0, err + } + if total.Valid { + return total.Float64, nil + } + return 0, nil +} + // --------------------------------------------------------------------------- // Event queries // --------------------------------------------------------------------------- // CreateEvent inserts a new event row with optional base currency and exchange rate. -func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate float64) error { +func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error { if baseCurrency == "" { baseCurrency = "EUR" } @@ -300,12 +431,12 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate exchangeRate = 1.0 } _, err := db.Exec( - "INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)", - id, userID, name, baseCurrency, exchangeRate, + "INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)", + id, userID, monthID, name, baseCurrency, exchangeRate, ) if err != nil { - log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v", - time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err) + log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v", + time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, err) } return err } @@ -313,7 +444,7 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate // GetEventsByUser returns all events belonging to a user, ordered by creation date descending. func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { rows, err := db.Query( - "SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", + "SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", userID, ) if err != nil { @@ -326,7 +457,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { var events []Event for rows.Next() { var e Event - if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { log.Printf("ERROR [%s] database: GetEventsByUser scan: %v", time.Now().Format(time.RFC3339), err) return nil, err @@ -336,11 +467,37 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { return events, rows.Err() } +// GetEventsByMonth returns all events under a given month, ordered by creation date descending. +func GetEventsByMonth(db *sql.DB, monthID string) ([]Event, error) { + rows, err := db.Query( + "SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE month_id = ? ORDER BY created_at DESC", + monthID, + ) + if err != nil { + log.Printf("ERROR [%s] database: GetEventsByMonth(%s): %v", + time.Now().Format(time.RFC3339), monthID, err) + return nil, err + } + defer rows.Close() + + var events []Event + for rows.Next() { + var e Event + if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + log.Printf("ERROR [%s] database: GetEventsByMonth scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + // GetEventByID returns a single event by ID, or nil if not found. func GetEventByID(db *sql.DB, id string) (*Event, error) { - row := db.QueryRow("SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id) + row := db.QueryRow("SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id) e := &Event{} - if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } diff --git a/internal/handlers/events.go b/internal/handlers/events.go index 428acd2..0611325 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -1,7 +1,7 @@ // Package handlers provides HTTP request handlers for NextExpense. // -// This file implements event management endpoints including dashboard -// listing, event creation, reopening, and expense viewing. +// This file implements event management endpoints including event creation, +// reopening, closing, and expense viewing — all scoped under a parent month. package handlers import ( @@ -36,66 +36,32 @@ func NewEventHandler(db *sql.DB) *EventHandler { } // --------------------------------------------------------------------------- -// GET /dashboard — Dashboard +// POST /months/{mid}/events — CreateEvent // --------------------------------------------------------------------------- -// Dashboard renders the main dashboard page showing all events belonging -// to the authenticated user, along with the new event creation form. -func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) { - userID := getUserID(r) - if userID == "" { - log.Printf("ERROR [%s] handlers: Dashboard: 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 - } - - events, err := database.GetEventsByUser(h.DB, userID) - if err != nil { - log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v", - time.Now().Format(time.RFC3339), err) - http.Error(w, "Failed to load events", http.StatusInternalServerError) - return - } - - tmpl := getTemplate("dashboard.html") - - data := map[string]interface{}{ - "Events": events, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v", - time.Now().Format(time.RFC3339), err) - } -} - -// --------------------------------------------------------------------------- -// POST /events — CreateEvent -// --------------------------------------------------------------------------- - -// CreateEvent handles the creation of a new event for the authenticated user. -// It reads the event name from the form, generates a UUID, persists the -// event, and redirects to the dashboard via HX-Redirect. +// CreateEvent handles the creation of a new event under a given month. func (h *EventHandler) CreateEvent(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 == "" { - log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + name := r.FormValue("name") if name == "" { - log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339)) http.Error(w, "Event name is required", http.StatusBadRequest) return } @@ -105,8 +71,7 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { baseCurrency = "USD" } - // Compute exchange rate from user-provided sample: - // e.g. receipt=1000 KES, claimed=7.73 USD → rate = 7.73 / 1000 = 0.00773 + // Compute exchange rate from user-provided sample. exchangeRate := 1.0 sampleReceipt := r.FormValue("sample_receipt_amount") sampleClaim := r.FormValue("sample_claim_amount") @@ -115,201 +80,176 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64) if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 { exchangeRate = sampleClaimVal / sampleReceiptVal - log.Printf("INFO [%s] handlers: CreateEvent: computed rate %.6f from sample %s %s → %.2f %s", - time.Now().Format(time.RFC3339), exchangeRate, - r.FormValue("sample_receipt_currency"), sampleReceipt, sampleClaimVal, baseCurrency) } } id := utils.NewUUID() - if err := database.CreateEvent(h.DB, id, userID, name, baseCurrency, exchangeRate); err != nil { + if err := database.CreateEvent(h.DB, id, userID, monthID, name, baseCurrency, exchangeRate); err != nil { log.Printf("ERROR [%s] handlers: CreateEvent: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to create event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// PUT /events/{id}/reopen — ReopenEvent +// PUT /months/{mid}/events/{eid}/reopen — ReopenEvent // --------------------------------------------------------------------------- -// ReopenEvent sets an event's status back to "open" and returns an HTMX -// fragment replacing the event's status badge with a green "open" badge. -// It verifies that the requesting user owns the event. +// ReopenEvent sets an event's status back to "open". Verifies month and event ownership. func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID", - time.Now().Format(time.RFC3339)) - http.Error(w, "Missing event ID", http.StatusBadRequest) + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { - log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID", - time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - event, err := database.GetEventByID(h.DB, eventID) - if err != nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to retrieve event", http.StatusInternalServerError) - return - } - if event == nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found", - time.Now().Format(time.RFC3339), eventID) - http.Error(w, "Event not found", http.StatusNotFound) - return - } - if event.UserID != userID { - log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s", - time.Now().Format(time.RFC3339), eventID) + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } - if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to reopen event", http.StatusInternalServerError) - return - } - - // Clean up any stale download packages — user must regenerate after reopening. - if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { - for _, fn := range oldFiles { - os.Remove(filepath.Join("storage", "postbox", fn)) - } - } - - // Redirect to dashboard so the full page renders with updated status. - w.Header().Set("HX-Redirect", "/dashboard") - w.WriteHeader(http.StatusOK) -} - -// --------------------------------------------------------------------------- -// POST /events/{id}/close — CloseEvent -// --------------------------------------------------------------------------- - -// CloseEvent sets an event's status to "closed". Only the event owner may -// close it. On success, redirects to the dashboard. -func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - http.Error(w, "Missing event ID", http.StatusBadRequest) - return - } - - userID := getUserID(r) - if userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } - if event.UserID != userID { + if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } - if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { - log.Printf("ERROR [%s] handlers: CloseEvent: UpdateEventStatus(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to close event", http.StatusInternalServerError) + if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { + log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to reopen event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + // Clean up stale download packages. + if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { + for _, fn := range oldFiles { + os.Remove(filepath.Join("storage", "postbox", fn)) + } + } + + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// GET /events/{id}/expenses — ViewEventExpenses +// POST /months/{mid}/events/{eid}/close — CloseEvent // --------------------------------------------------------------------------- -// ViewEventExpenses displays the expense collection view for a specific event. -// It verifies event ownership, sets the current_event_id cookie, and renders -// the event_expenses.html template with the event and its expense list. -func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID", - time.Now().Format(time.RFC3339)) - http.Error(w, "Missing event ID", http.StatusBadRequest) +// CloseEvent sets an event's status to "closed". +func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID", - time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - event, err := database.GetEventByID(h.DB, eventID) - if err != nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to retrieve event", http.StatusInternalServerError) + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) return } - if event == nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found", - time.Now().Format(time.RFC3339), eventID) + + event, err := database.GetEventByID(h.DB, eventID) + if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } - if event.UserID != userID { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s", - time.Now().Format(time.RFC3339), eventID) + if event.MonthID != monthID || event.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { + log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to close event", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/months/"+monthID) + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses +// --------------------------------------------------------------------------- + +// ViewEventExpenses displays the expense collection view for a specific event. +func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) + return + } + + userID := getUserID(r) + if userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + event, err := database.GetEventByID(h.DB, eventID) + if err != nil || event == nil { + http.Error(w, "Event not found", http.StatusNotFound) + return + } + if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) + log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to load expenses", http.StatusInternalServerError) return } - // Set the current_event_id cookie so subsequent expense operations - // (SaveExpense, UploadReceipt) know which event to associate with. + // Set current_event_id cookie for expense operations. setCurrentEventID(w, eventID) tmpl := getTemplate("event_expenses.html") - totalClaim := 0.0 - for _, exp := range expenses { - if exp.ConvertedAmount > 0 { - totalClaim += exp.ConvertedAmount - } - } - - // Normalize ImagePath for all expenses (old DB entries may have storage/ prefix). for i := range expenses { expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath) } data := map[string]interface{}{ - "Event": event, - "Expenses": expenses, - "TotalClaim": totalClaim, + "Month": month, + "Event": event, + "Expenses": expenses, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -320,56 +260,59 @@ func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) } // --------------------------------------------------------------------------- -// GET /events/{id}/edit — EditEvent +// GET /months/{mid}/events/{eid}/edit — EditEvent // --------------------------------------------------------------------------- // EditEvent returns the event edit form fragment pre-filled with current data. func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } - // Build a slug for the sample receipt currency (use base currency as default). rcptCur := event.BaseCurrency if rcptCur == "" { rcptCur = "KES" } - // Compute a rough reverse sample from the exchange rate: - // rate = claim / receipt → sample_receipt = 1000, sample_claim = 1000 * rate sampleClm := event.ExchangeRate * 1000 w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `

Edit Event

-
+
-
- - -
-
-
Conversion Sample
-

Update the sample to recalculate the rate.

-
-
- +
+
Conversion Rate
+
+
+ + +
+
+ + +
+
+
+
+
-
+
-
- - +
+ Rate = Claim / Local
@@ -378,21 +321,23 @@ func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) { onclick="document.getElementById('create-event-form').innerHTML='';document.getElementById('create-event-form').classList.add('hidden')">Cancel - +
-
`, event.ID, template.HTMLEscapeString(event.Name), template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), sampleClm, event.ID, event.ID, event.ID) +
`, monthID, event.ID, template.HTMLEscapeString(event.Name), sampleClm, template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), event.ID, monthID, event.ID, event.ID) } // --------------------------------------------------------------------------- -// PUT /events/{id} — UpdateEvent +// PUT /months/{mid}/events/{eid} — UpdateEvent // --------------------------------------------------------------------------- // UpdateEvent updates the event's base currency and exchange rate. func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } @@ -419,33 +364,34 @@ func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { return } - // Recalculate all existing expense converted amounts with the new rate. if err := database.RecalculateExpenses(h.DB, eventID, baseCurrency, exchangeRate); err != nil { log.Printf("ERROR [%s] handlers: UpdateEvent: recalc expenses: %v", time.Now().Format(time.RFC3339), err) - // Non-fatal — the event was updated, but expenses may have stale conversions. } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// DELETE /events/{id} — DeleteEvent +// DELETE /months/{mid}/events/{eid} — DeleteEvent // --------------------------------------------------------------------------- // DeleteEvent removes an event and its expenses after ownership verification. func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } + if err := database.DeleteEvent(h.DB, eventID); err != nil { log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err) http.Error(w, "Failed to delete event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index e615ad1..a061f13 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -261,6 +261,9 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) { if date == "" { missing = append(missing, "date") } + if description == "" { + missing = append(missing, "description") + } if len(missing) > 0 { log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s", time.Now().Format(time.RFC3339), strings.Join(missing, ", ")) @@ -373,12 +376,16 @@ func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, expense.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } tmpl := getTemplate("receipt_form.html") @@ -442,12 +449,16 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, existing.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } expense := database.Expense{ ID: expenseID, @@ -520,12 +531,16 @@ func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, existing.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } eventID := existing.EventID diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 6141016..42eaacd 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -47,24 +47,15 @@ type FileHandler struct { // FileEvent generates an expense report (CSV or PDF) for a given event and // emails it as an attachment to the specified recipient. On success the // event status is updated to "closed" and the client is redirected to the -// dashboard via the HX-Redirect header. -// -// Flow: -// 1. Extract event ID from the URL via chi.URLParam -// 2. Parse the form for target email and report format -// 3. Verify the authenticated user owns this event -// 4. Fetch all expenses for the event from the database -// 5. Generate the report in the requested format (CSV or PDF) -// 6. Send the report as an email attachment -// 7. Update the event status to "closed" -// 8. Return an HX-Redirect header pointing to /dashboard +// month view via the HX-Redirect header. func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { - // 1. Get event ID from the URL path parameter. - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL", + // 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 event ID.") + renderFileError(w, "Missing ID.") return } @@ -85,7 +76,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } - // 3. Verify the authenticated user owns this event. + // 3. Verify the authenticated user owns this event (via month). userID := getUserID(r) if userID == "" { log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", @@ -94,6 +85,11 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { 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", @@ -107,7 +103,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { renderFileError(w, "Event not found.") return } - if event.UserID != userID { + 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.") @@ -184,8 +180,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } - // 8. Redirect to the dashboard via HTMX. - w.Header().Set("HX-Redirect", "/dashboard") + // 8. Redirect to the month view via HTMX. + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } @@ -198,11 +194,12 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // HTMX fragment with download and email-link options. The event is NOT // closed — the user can add more receipts and regenerate. func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID", + 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 event ID.") + renderFileError(w, "Missing ID.") return } @@ -219,12 +216,17 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { 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.UserID != userID { + if event.MonthID != monthID || event.UserID != userID { renderFileError(w, "You do not have permission to access this event.") return } @@ -345,7 +347,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {

Or send a download link via email (tiny email, no attachment limits):

-
+ @@ -356,17 +358,18 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
`, len(expenses), template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), - template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) + template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) } // --------------------------------------------------------------------------- -// POST /events/{id}/send-link — SendDownloadLink +// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink // --------------------------------------------------------------------------- // SendDownloadLink emails a download link for a previously generated report -// package to the specified recipient. Returns an HTMX fragment with success -// or error feedback. +// 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, `
Failed to parse form.
`) @@ -390,7 +393,7 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { } event, err := database.GetEventByID(h.DB, dt.EventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + 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, `
Permission denied.
`) return diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 18d0e9a..e58071d 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -1,6 +1,11 @@ package handlers -import "strings" +import ( + "database/sql" + "strings" + + "github.com/cclohmar/NextExpense/internal/database" +) // normalizeImagePath strips a legacy "storage/" prefix if present, so that // the template can safely build "/storage/{filename}" URLs regardless of @@ -8,3 +13,16 @@ import "strings" func normalizeImagePath(path string) string { return strings.TrimPrefix(path, "storage/") } + +// verifyMonthOwnership checks that a month exists and belongs to the given user. +// Returns true if the month is owned by the user, false otherwise. +func verifyMonthOwnership(db *sql.DB, monthID, userID string) bool { + if monthID == "" { + return false + } + month, err := database.GetMonthByID(db, monthID) + if err != nil || month == nil { + return false + } + return month.UserID == userID +} diff --git a/internal/handlers/months.go b/internal/handlers/months.go new file mode 100644 index 0000000..d675ff8 --- /dev/null +++ b/internal/handlers/months.go @@ -0,0 +1,758 @@ +// 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 + } + + 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, `
+
Monthly Report Ready
+

%s & %d events, %d receipt images packaged.

+
+ ⬇ Download Now +
+
+

Or send a download link via email:

+
+ + + +
+ + +
+
`, + 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, `
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) + + 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 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 +} diff --git a/main.go b/main.go index 797519c..181821b 100644 --- a/main.go +++ b/main.go @@ -100,6 +100,9 @@ func main() { EmailSender: emailSender, } + monthHandler := handlers.NewMonthHandler(db) + monthHandler.EmailSender = emailSender + eventHandler := handlers.NewEventHandler(db) expenseHandler := handlers.NewExpenseHandler(db) fileHandler := &handlers.FileHandler{ @@ -209,19 +212,30 @@ func main() { r.Group(func(r chi.Router) { r.Use(authHandler.RequireAuth) - // Events. - r.Get("/dashboard", eventHandler.Dashboard) + // Dashboard (months). + r.Get("/dashboard", monthHandler.ListMonths) r.Get("/onboarding", authHandler.OnboardingPage) r.Post("/onboarding", authHandler.SaveOnboarding) r.Get("/profile", authHandler.ProfilePage) r.Post("/profile", authHandler.SaveProfile) - r.Post("/events", eventHandler.CreateEvent) - r.Put("/events/{id}", eventHandler.UpdateEvent) - r.Get("/events/{id}/edit", eventHandler.EditEvent) - r.Delete("/events/{id}", eventHandler.DeleteEvent) - r.Put("/events/{id}/reopen", eventHandler.ReopenEvent) - r.Post("/events/{id}/close", eventHandler.CloseEvent) - r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses) + + // Months. + r.Post("/months", monthHandler.CreateMonth) + r.Put("/months/{mid}", monthHandler.UpdateMonth) + r.Delete("/months/{mid}", monthHandler.DeleteMonth) + r.Get("/months/{mid}/edit", monthHandler.EditMonth) + r.Get("/months/{mid}", monthHandler.ViewMonth) + r.Post("/months/{mid}/generate", monthHandler.GenerateMonthlyReport) + r.Post("/months/{mid}/send-link", monthHandler.SendMonthlyDownloadLink) + + // Events (scoped under months). + r.Post("/months/{mid}/events", eventHandler.CreateEvent) + r.Put("/months/{mid}/events/{eid}", eventHandler.UpdateEvent) + r.Get("/months/{mid}/events/{eid}/edit", eventHandler.EditEvent) + r.Delete("/months/{mid}/events/{eid}", eventHandler.DeleteEvent) + r.Put("/months/{mid}/events/{eid}/reopen", eventHandler.ReopenEvent) + r.Post("/months/{mid}/events/{eid}/close", eventHandler.CloseEvent) + r.Get("/months/{mid}/events/{eid}/expenses", eventHandler.ViewEventExpenses) // Expenses. r.Post("/expenses/upload", expenseHandler.UploadReceipt) @@ -230,10 +244,10 @@ func main() { r.Put("/expenses/{id}", expenseHandler.UpdateExpense) r.Delete("/expenses/{id}", expenseHandler.DeleteExpense) - // Filing. - r.Post("/events/{id}/file", fileHandler.FileEvent) - r.Post("/events/{id}/generate", fileHandler.GenerateReport) - r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink) + // Filing (event-level). + r.Post("/months/{mid}/events/{eid}/file", fileHandler.FileEvent) + r.Post("/months/{mid}/events/{eid}/generate", fileHandler.GenerateReport) + r.Post("/months/{mid}/events/{eid}/send-link", fileHandler.SendDownloadLink) // Storage (receipt images) — protected by auth + path traversal check. r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) { diff --git a/static/css/style.css b/static/css/style.css index d7a85b2..48cfc81 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -129,6 +129,23 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Center app in a mobile-width shell on desktop */ +.app-shell { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + min-height: 100dvh; + border-left: 1px solid var(--color-border); + border-right: 1px solid var(--color-border); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); +} + +@media (min-width: 481px) { + body { + background-color: #070d19; + } +} + img { max-width: 100%; height: auto; @@ -1902,3 +1919,13 @@ small, .text-sm { .animate-stagger > *:nth-child(4) { animation-delay: 180ms; } .animate-stagger > *:nth-child(5) { animation-delay: 240ms; } .animate-stagger > *:nth-child(6) { animation-delay: 300ms; } + +/* Month card — left border accent distinguishes from event cards */ +.month-card { + transition: border-color var(--transition-base), background var(--transition-base); +} +.month-card:hover { + border-color: var(--color-primary); + background: rgba(16, 185, 129, 0.05); +} + diff --git a/static/favicon.svg b/static/favicon.svg index 4938093..80cbeed 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1,4 +1,4 @@ - Rx + Nx diff --git a/static/icons/icon-180.png b/static/icons/icon-180.png index ec9fadc..6ac1323 100644 Binary files a/static/icons/icon-180.png and b/static/icons/icon-180.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png index 3979e04..c83baaf 100644 Binary files a/static/icons/icon-192.png and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png index aa924b1..3d69c25 100644 Binary files a/static/icons/icon-512.png and b/static/icons/icon-512.png differ diff --git a/static/sw.js b/static/sw.js index aa961e3..5c10f0e 100644 --- a/static/sw.js +++ b/static/sw.js @@ -5,7 +5,7 @@ * Strategy: Cache-first for shell assets, network-only for API * ============================================================ */ -const CACHE_NAME = 'nextexpense-v1'; +const CACHE_NAME = 'nextexpense-v2'; // Shell assets to pre-cache on install const SHELL_ASSETS = [ diff --git a/templates/dashboard.html b/templates/dashboard.html index d8ccc7f..2168325 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -8,7 +8,7 @@ - + @@ -25,103 +25,90 @@
-

My Events

+

My Months

-