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, ``)
+ 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}}