From ded4954c72ed9d4195b3a7f1e35bb6fcb3c7ba37 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sat, 30 May 2026 12:43:33 +0000 Subject: [PATCH] feat: add expense editing - view and edit all existing receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added ✏️ edit button on every expense in the list - Clicking loads the receipt form pre-filled with the expense data - Changes are saved via PUT /expenses/{id} - Receipt form auto-switches between create (POST) and edit (PUT) mode - Full HTMX multi-target response: form resets + list refreshes --- internal/database/db.go | 36 +++++++++ internal/handlers/expenses.go | 143 ++++++++++++++++++++++++++++++++++ main.go | 2 + templates/dashboard.html | 5 ++ templates/event_expenses.html | 9 +++ templates/expense_list.html | 9 +++ templates/receipt_form.html | 8 +- 7 files changed, 211 insertions(+), 1 deletion(-) diff --git a/internal/database/db.go b/internal/database/db.go index 0c739ea..27812a0 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -336,3 +336,39 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) { } return expenses, rows.Err() } + +// GetExpenseByID returns a single expense by its ID, or nil if not found. +func GetExpenseByID(db *sql.DB, id string) (*Expense, error) { + row := db.QueryRow( + `SELECT id, event_id, amount, currency, converted_amount, base_currency, + merchant, category, COALESCE(description, ''), date, image_path, created_at + FROM expenses WHERE id = ?`, id) + e := &Expense{} + if err := row.Scan( + &e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency, + &e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt, + ); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetExpenseByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return e, nil +} + +// UpdateExpense updates all editable fields of an existing expense. +func UpdateExpense(db *sql.DB, expense Expense) error { + _, err := db.Exec( + `UPDATE expenses SET amount=?, currency=?, converted_amount=?, base_currency=?, + merchant=?, category=?, description=?, date=? WHERE id=?`, + expense.Amount, expense.Currency, expense.ConvertedAmount, expense.BaseCurrency, + expense.Merchant, expense.Category, expense.Description, expense.Date, expense.ID, + ) + if err != nil { + log.Printf("ERROR [%s] database: UpdateExpense(%s): %v", + time.Now().Format(time.RFC3339), expense.ID, err) + } + return err +} diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index 215e460..4faf68e 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -20,6 +20,7 @@ import ( "github.com/expenseflow/internal/ai" "github.com/expenseflow/internal/database" "github.com/expenseflow/internal/utils" + "github.com/go-chi/chi/v5" ) // --------------------------------------------------------------------------- @@ -333,6 +334,148 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `
%s
`, listBuf.String()) } +// --------------------------------------------------------------------------- +// GET /expenses/{id}/edit — EditExpense +// --------------------------------------------------------------------------- + +// EditExpense returns the receipt form pre-filled with an existing expense's +// data, allowing the user to edit and re-save it. +func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) { + expenseID := chi.URLParam(r, "id") + if expenseID == "" { + http.Error(w, "Missing expense ID", http.StatusBadRequest) + return + } + + expense, err := database.GetExpenseByID(h.DB, expenseID) + if err != nil { + log.Printf("ERROR [%s] handlers: EditExpense: GetExpenseByID(%s): %v", + time.Now().Format(time.RFC3339), expenseID, err) + http.Error(w, "Failed to retrieve expense", http.StatusInternalServerError) + return + } + if expense == nil { + http.Error(w, "Expense not found", http.StatusNotFound) + return + } + + tmpl, err := template.ParseFiles("templates/receipt_form.html") + if err != nil { + log.Printf("ERROR [%s] handlers: EditExpense: parse template: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + data := map[string]interface{}{ + "ImagePath": expense.ImagePath, + "AIError": "", + "Amount": strconv.FormatFloat(expense.Amount, 'f', 2, 64), + "Currency": expense.Currency, + "Merchant": expense.Merchant, + "Category": expense.Category, + "Date": expense.Date, + "Description": expense.Description, + "BaseCurrency": expense.BaseCurrency, + "ExchangeRate": 0.0, + "ConvertedAmount": strconv.FormatFloat(expense.ConvertedAmount, 'f', 2, 64), + "EditID": expense.ID, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR [%s] handlers: EditExpense: execute template: %v", + time.Now().Format(time.RFC3339), err) + } +} + +// --------------------------------------------------------------------------- +// PUT /expenses/{id} — UpdateExpense +// --------------------------------------------------------------------------- + +// UpdateExpense updates an existing expense record with form data and returns +// the updated expense list via HTMX multi-target response. +func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { + expenseID := chi.URLParam(r, "id") + if expenseID == "" { + http.Error(w, "Missing expense ID", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: parse form: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Cannot parse form data", http.StatusBadRequest) + return + } + + amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64) + convertedAmount, _ := strconv.ParseFloat(r.FormValue("converted_amount"), 64) + baseCurrency := r.FormValue("base_currency") + if baseCurrency == "" { + baseCurrency = r.FormValue("currency") + convertedAmount = amount + } + + // Fetch the existing expense to preserve the event_id and image_path. + existing, err := database.GetExpenseByID(h.DB, expenseID) + if err != nil || existing == nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: get existing: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Expense not found", http.StatusNotFound) + return + } + + expense := database.Expense{ + ID: expenseID, + EventID: existing.EventID, + Amount: amount, + Currency: r.FormValue("currency"), + ConvertedAmount: convertedAmount, + BaseCurrency: baseCurrency, + Merchant: r.FormValue("merchant"), + Category: r.FormValue("category"), + Description: r.FormValue("description"), + Date: r.FormValue("date"), + ImagePath: existing.ImagePath, + } + + if err := database.UpdateExpense(h.DB, expense); err != nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: %v", time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to update expense", http.StatusInternalServerError) + return + } + + // Return updated expense list via HTMX. + expenses, err := database.GetExpensesByEvent(h.DB, existing.EventID) + if err != nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: fetch expenses: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError) + return + } + + listTmpl, err := template.ParseFiles("templates/expense_list.html") + if err != nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: parse template: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + var listBuf strings.Builder + if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil { + log.Printf("ERROR [%s] handlers: UpdateExpense: execute template: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Expense updated successfully!
`) + fmt.Fprintf(w, `
%s
`, listBuf.String()) +} + // --------------------------------------------------------------------------- // Cookie helpers (shared with events.go via package-level access) // --------------------------------------------------------------------------- diff --git a/main.go b/main.go index 4c81d8d..1f4ed7b 100644 --- a/main.go +++ b/main.go @@ -174,6 +174,8 @@ func main() { // Expenses. r.Post("/expenses/upload", expenseHandler.UploadReceipt) r.Post("/expenses", expenseHandler.SaveExpense) + r.Get("/expenses/{id}/edit", expenseHandler.EditExpense) + r.Put("/expenses/{id}", expenseHandler.UpdateExpense) // Filing. r.Post("/events/{id}/file", fileHandler.FileEvent) diff --git a/templates/dashboard.html b/templates/dashboard.html index a46391a..2eca3f7 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -113,6 +113,11 @@ hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true"> Add Expenses + + File Report + {{else}} + {{end}} diff --git a/templates/expense_list.html b/templates/expense_list.html index c2d4b16..5df4dbd 100644 --- a/templates/expense_list.html +++ b/templates/expense_list.html @@ -28,6 +28,15 @@ {{end}} +
+ +
{{end}} diff --git a/templates/receipt_form.html b/templates/receipt_form.html index 27d2a2b..2c77b65 100644 --- a/templates/receipt_form.html +++ b/templates/receipt_form.html @@ -15,9 +15,15 @@ AI Extracted + {{if .EditID}} +
+ {{else}} + {{end}} + {{if .EditID}}{{end}}
@@ -109,7 +115,7 @@