From 99a002c60771be88c450a906b88b7133e9bbe088 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 14:46:27 -0500 Subject: [PATCH 1/2] feat: extend expense card info and allow edit/delete of expense --- internal/handler/space.go | 151 ++++++++++++++++++- internal/model/expense.go | 5 + internal/repository/expense.go | 89 ++++++++++- internal/routes/routes.go | 8 + internal/service/expense.go | 90 +++++++++++ internal/ui/components/expense/expense.templ | 100 ++++++++++++ internal/ui/pages/app_space_expenses.templ | 97 ++++++++++-- 7 files changed, 516 insertions(+), 24 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 254f580..198aafe 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -38,6 +38,20 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv } } +// getExpenseForSpace fetches an expense and verifies it belongs to the given space. +func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense { + exp, err := h.expenseService.GetExpense(expenseID) + if err != nil { + http.Error(w, "Expense not found", http.StatusNotFound) + return nil + } + if exp.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return exp +} + // getListForSpace fetches a shopping list and verifies it belongs to the given space. // Returns the list on success, or writes an error response and returns nil. func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList { @@ -401,7 +415,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -558,7 +572,136 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { slog.Error("failed to get balance", "error", err, "space_id", spaceID) } - ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance)) + // Build tags for the newly created expense + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID}) + newExpenseWithTags := &model.ExpenseWithTags{ + Expense: *newExpense, + Tags: tagsMap[newExpense.ID], + } + + ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance)) +} + +func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + description := r.FormValue("description") + amountStr := r.FormValue("amount") + typeStr := r.FormValue("type") + dateStr := r.FormValue("date") + tagNames := r.Form["tags"] + + if description == "" || amountStr == "" || typeStr == "" || dateStr == "" { + http.Error(w, "All fields are required.", http.StatusBadRequest) + return + } + + amountFloat, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + http.Error(w, "Invalid amount format.", http.StatusBadRequest) + return + } + amountCents := int(amountFloat * 100) + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "Invalid date format.", http.StatusBadRequest) + return + } + + expenseType := model.ExpenseType(typeStr) + if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { + http.Error(w, "Invalid transaction type.", http.StatusBadRequest) + return + } + + // Tag processing (same as CreateExpense) + existingTags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + existingTagsMap := make(map[string]string) + for _, t := range existingTags { + existingTagsMap[t.Name] = t.ID + } + + var finalTagIDs []string + processedTags := make(map[string]bool) + + for _, rawTagName := range tagNames { + tagName := service.NormalizeTagName(rawTagName) + if tagName == "" || processedTags[tagName] { + continue + } + + if id, exists := existingTagsMap[tagName]; exists { + finalTagIDs = append(finalTagIDs, id) + } else { + newTag, err := h.tagService.CreateTag(spaceID, tagName, nil) + if err != nil { + slog.Error("failed to create new tag from expense form", "error", err, "tag_name", tagName) + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + existingTagsMap[tagName] = newTag.ID + } + processedTags[tagName] = true + } + + dto := service.UpdateExpenseDTO{ + ID: expenseID, + SpaceID: spaceID, + Description: description, + Amount: amountCents, + Type: expenseType, + Date: date, + TagIDs: finalTagIDs, + } + + updatedExpense, err := h.expenseService.UpdateExpense(dto) + if err != nil { + slog.Error("failed to update expense", "error", err) + http.Error(w, "Failed to update expense.", http.StatusInternalServerError) + return + } + + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID}) + expWithTags := &model.ExpenseWithTags{ + Expense: *updatedExpense, + Tags: tagsMap[updatedExpense.ID], + } + + ui.Render(w, r, pages.ExpenseListItem(spaceID, expWithTags)) +} + +func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := h.expenseService.DeleteExpense(expenseID, spaceID); err != nil { + slog.Error("failed to delete expense", "error", err, "expense_id", expenseID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) } func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { @@ -637,14 +780,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.ExpensesListContent(expenses)) + ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses)) } func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/expense.go b/internal/model/expense.go index 48713e3..fa15f3e 100644 --- a/internal/model/expense.go +++ b/internal/model/expense.go @@ -21,6 +21,11 @@ type Expense struct { UpdatedAt time.Time `db:"updated_at"` } +type ExpenseWithTags struct { + Expense + Tags []*Tag +} + type ExpenseTag struct { ExpenseID string `db:"expense_id"` TagID string `db:"tag_id"` diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 60d89b9..18588eb 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -18,6 +18,9 @@ type ExpenseRepository interface { GetByID(id string) (*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) + GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) + Update(expense *model.Expense, tagIDs []string) error + Delete(id string) error } type expenseRepository struct { @@ -91,10 +94,10 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { var summaries []*model.TagExpenseSummary query := ` - SELECT - t.id as tag_id, - t.name as tag_name, - t.color as tag_color, + SELECT + t.id as tag_id, + t.name as tag_name, + t.color as tag_color, SUM(e.amount_cents) as total_amount FROM expenses e JOIN expense_tags et ON e.id = et.expense_id @@ -109,3 +112,81 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti } return summaries, nil } + +func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + if len(expenseIDs) == 0 { + return make(map[string][]*model.Tag), nil + } + + type row struct { + ExpenseID string `db:"expense_id"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + Name string `db:"name"` + Color *string `db:"color"` + } + + query, args, err := sqlx.In(` + SELECT et.expense_id, t.id, t.space_id, t.name, t.color + FROM expense_tags et + JOIN tags t ON et.tag_id = t.id + WHERE et.expense_id IN (?) + ORDER BY t.name; + `, expenseIDs) + if err != nil { + return nil, err + } + query = r.db.Rebind(query) + + var rows []row + if err := r.db.Select(&rows, query, args...); err != nil { + return nil, err + } + + result := make(map[string][]*model.Tag) + for _, rw := range rows { + result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{ + ID: rw.ID, + SpaceID: rw.SpaceID, + Name: rw.Name, + Color: rw.Color, + }) + } + return result, nil +} + +func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error { + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;` + _, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID) + if err != nil { + return err + } + + // Replace tags: delete all existing, re-insert + _, err = tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID) + if err != nil { + return err + } + + if len(tagIDs) > 0 { + insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (r *expenseRepository) Delete(id string) error { + _, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index a671cc6..c64dbb0 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -122,6 +122,14 @@ func SetupRoutes(a *app.App) http.Handler { createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + updateExpenseHandler := middleware.RequireAuth(space.UpdateExpense) + updateExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateExpenseHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", updateExpenseWithAccess) + + deleteExpenseHandler := middleware.RequireAuth(space.DeleteExpense) + deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess) + // Component routes (HTMX updates) balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard) balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler) diff --git a/internal/service/expense.go b/internal/service/expense.go index 96bf545..9d367b4 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -21,6 +21,16 @@ type CreateExpenseDTO struct { ItemIDs []string } +type UpdateExpenseDTO struct { + ID string + SpaceID string + Description string + Amount int + Type model.ExpenseType + Date time.Time + TagIDs []string +} + type ExpenseService struct { expenseRepo repository.ExpenseRepository eventBus *event.Broker @@ -95,3 +105,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) { func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate) } + +func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) { + expenses, err := s.expenseRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + + ids := make([]string, len(expenses)) + for i, e := range expenses { + ids[i] = e.ID + } + + tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids) + if err != nil { + return nil, err + } + + result := make([]*model.ExpenseWithTags, len(expenses)) + for i, e := range expenses { + result[i] = &model.ExpenseWithTags{ + Expense: *e, + Tags: tagsMap[e.ID], + } + } + return result, nil +} + +func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) { + return s.expenseRepo.GetByID(id) +} + +func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs) +} + +func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) { + if dto.Description == "" { + return nil, fmt.Errorf("expense description cannot be empty") + } + if dto.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + existing, err := s.expenseRepo.GetByID(dto.ID) + if err != nil { + return nil, err + } + + existing.Description = dto.Description + existing.AmountCents = dto.Amount + existing.Type = dto.Type + existing.Date = dto.Date + existing.UpdatedAt = time.Now() + + if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil { + return nil, err + } + + balance, _ := s.GetBalanceForSpace(dto.SpaceID) + s.eventBus.Publish(dto.SpaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(dto.SpaceID, "expenses_updated", nil) + + return existing, nil +} + +func (s *ExpenseService) DeleteExpense(id string, spaceID string) error { + if err := s.expenseRepo.Delete(id); err != nil { + return err + } + + balance, _ := s.GetBalanceForSpace(spaceID) + s.eventBus.Publish(spaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(spaceID, "expenses_updated", nil) + + return nil +} diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 0cf2e7e..228868c 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -209,6 +209,106 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []mod } +templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) { + {{ editDialogID := "edit-expense-" + exp.ID }} + {{ tagValues := make([]string, len(exp.Tags)) }} + for i, t := range exp.Tags { + {{ tagValues[i] = t.Name }} + } +
+ @csrf.Token() + // Type +
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-expense-" + exp.ID, + Name: "type", + Value: "expense", + Checked: exp.Type == model.ExpenseTypeExpense, + }) +
+ @label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) { + Expense + } +
+
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-topup-" + exp.ID, + Name: "type", + Value: "topup", + Checked: exp.Type == model.ExpenseTypeTopup, + }) +
+ @label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) { + Top-up + } +
+
+
+ // Description +
+ @label.Label(label.Props{For: "edit-description-" + exp.ID}) { + Description + } + @input.Input(input.Props{ + Name: "description", + ID: "edit-description-" + exp.ID, + Value: exp.Description, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Amount +
+ @label.Label(label.Props{For: "edit-amount-" + exp.ID}) { + Amount + } + @input.Input(input.Props{ + Name: "amount", + ID: "edit-amount-" + exp.ID, + Type: "number", + Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0), + Attributes: templ.Attributes{"step": "0.01", "required": "true"}, + }) +
+ // Date +
+ @label.Label(label.Props{For: "edit-date-" + exp.ID}) { + Date + } + @datepicker.DatePicker(datepicker.Props{ + ID: "edit-date-" + exp.ID, + Name: "date", + Value: exp.Date, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Tags +
+ @label.Label(label.Props{For: "edit-tags-" + exp.ID}) { + Tags + } + @tagsinput.TagsInput(tagsinput.Props{ + ID: "edit-tags-" + exp.ID, + Name: "tags", + Value: tagValues, + Placeholder: "Add tags (press enter)", + }) +
+
+ @button.Button(button.Props{Type: button.TypeSubmit}) { + Save + } +
+
+} + templ BalanceCard(spaceID string, balance int, oob bool) {
@@ -42,45 +44,108 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i hx-trigger="sse:expenses_updated" hx-swap="innerHTML" > - @ExpensesListContent(expenses) + @ExpensesListContent(space.ID, expenses)
} } -templ ExpensesListContent(expenses []*model.Expense) { +templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {

History

if len(expenses) == 0 {

No expenses recorded yet.

} - for _, expense := range expenses { - @ExpenseListItem(expense) + for _, exp := range expenses { + @ExpenseListItem(spaceID, exp) }
} -templ ExpenseListItem(expense *model.Expense) { -
-
-

{ expense.Description }

-

{ expense.Date.Format("Jan 02, 2006") }

+templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) { +
+
+

{ exp.Description }

+

{ exp.Date.Format("Jan 02, 2006") }

+ if len(exp.Tags) > 0 { +
+ for _, t := range exp.Tags { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + { t.Name } + } + } +
+ }
-
- if expense.Type == model.ExpenseTypeExpense { +
+ if exp.Type == model.ExpenseTypeExpense {

- - { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + - { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} else {

- + { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + + { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} + // Edit button + @dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Pencil(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Edit Transaction + } + @dialog.Description() { + Update the details of this transaction. + } + } + @expense.EditExpenseForm(spaceID, exp) + } + } + // Delete button + @dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Trash2(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Delete Transaction + } + @dialog.Description() { + Are you sure you want to delete "{ exp.Description }"? This action cannot be undone. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{Variant: button.VariantOutline}) { + Cancel + } + } + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Attributes: templ.Attributes{ + "hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID), + "hx-target": "#expense-" + exp.ID, + "hx-swap": "outerHTML", + }, + }) { + Delete + } + } + } + }
} -templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { - @ExpenseListItem(newExpense) +templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, newExpense) @expense.BalanceCard(newExpense.SpaceID, balance, true) } From 948aa092edef3bd79c1a90f5a57a08dd50256b4c Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 14:54:20 -0500 Subject: [PATCH 2/2] fix: expense edits does not update balance card --- internal/handler/space.go | 14 ++++++++++++-- internal/ui/pages/app_space_expenses.templ | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 198aafe..7884b4d 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -684,7 +684,12 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { Tags: tagsMap[updatedExpense.ID], } - ui.Render(w, r, pages.ExpenseListItem(spaceID, expWithTags)) + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after update", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTags, balance)) } func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { @@ -701,7 +706,12 @@ func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after delete", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, expense.BalanceCard(spaceID, balance, true)) } func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ index 2b69570..0e3e0ad 100644 --- a/internal/ui/pages/app_space_expenses.templ +++ b/internal/ui/pages/app_space_expenses.templ @@ -149,3 +149,8 @@ templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, @ExpenseListItem(spaceID, newExpense) @expense.BalanceCard(newExpense.SpaceID, balance, true) } + +templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, exp) + @expense.BalanceCard(exp.SpaceID, balance, true) +}