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