budgit/internal/handler/expense_handler.go
juancwu 89c5d76e5e
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s
Merge branch 'fix/calculation-accuracy' into main
Combines the decimal migration (int cents → decimal.Decimal via
shopspring/decimal) with main's handler refactor (split space.go into
domain handlers, WithTx/Paginate helpers, recurring deposit removal).

- Repository layer: WithTx pattern + decimal column names/types
- Handler layer: decimal arithmetic (.Sub/.Add) instead of int operators
- Models: deprecated amount_cents fields kept for SELECT * compatibility
- INSERT statements: old columns set to literal 0 for NOT NULL constraints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:48:40 -04:00

493 lines
15 KiB
Go

package handler
import (
"log/slog"
"net/http"
"strconv"
"time"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type ExpenseHandler struct {
spaceService *service.SpaceService
expenseService *service.ExpenseService
tagService *service.TagService
listService *service.ShoppingListService
accountService *service.MoneyAccountService
methodService *service.PaymentMethodService
}
func NewExpenseHandler(ss *service.SpaceService, es *service.ExpenseService, ts *service.TagService, sls *service.ShoppingListService, mas *service.MoneyAccountService, pms *service.PaymentMethodService) *ExpenseHandler {
return &ExpenseHandler{
spaceService: ss,
expenseService: es,
tagService: ts,
listService: sls,
accountService: mas,
methodService: pms,
}
}
// getExpenseForSpace fetches an expense and verifies it belongs to the given space.
func (h *ExpenseHandler) 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
}
func (h *ExpenseHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = decimal.Zero
}
balance = balance.Sub(totalAllocated)
tags, 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
}
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
if err != nil {
slog.Error("failed to get lists with unchecked items", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
methods, err := h.methodService.GetMethodsForSpace(spaceID)
if err != nil {
slog.Error("failed to get payment methods", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, totalAllocated, tags, listsWithItems, methods, page, totalPages))
if r.URL.Query().Get("created") == "true" {
ui.Render(w, r, toast.Toast(toast.Props{
Title: "Expense created",
Description: "Your transaction has been recorded.",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
}
func (h *ExpenseHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
// --- Form Parsing ---
description := r.FormValue("description")
amountStr := r.FormValue("amount")
typeStr := r.FormValue("type")
dateStr := r.FormValue("date")
tagNames := r.Form["tags"] // Contains tag names
// --- Validation & Conversion ---
if description == "" || amountStr == "" || typeStr == "" || dateStr == "" {
ui.RenderError(w, r, "All fields are required.", http.StatusUnprocessableEntity)
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return
}
amount := amountDecimal
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
ui.RenderError(w, r, "Invalid date format.", http.StatusUnprocessableEntity)
return
}
expenseType := model.ExpenseType(typeStr)
if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup {
ui.RenderError(w, r, "Invalid transaction type.", http.StatusUnprocessableEntity)
return
}
// --- Tag Processing ---
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 == "" {
continue
}
if processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
// Create new tag
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
}
// Parse payment_method_id
var paymentMethodID *string
if pmid := r.FormValue("payment_method_id"); pmid != "" {
paymentMethodID = &pmid
}
// Parse linked shopping list items
itemIDs := r.Form["item_ids"]
itemAction := r.FormValue("item_action")
// Only link items for expense type, not topup
if expenseType != model.ExpenseTypeExpense {
itemIDs = nil
}
dto := service.CreateExpenseDTO{
SpaceID: spaceID,
UserID: user.ID,
Description: description,
Amount: amount,
Type: expenseType,
Date: date,
TagIDs: finalTagIDs,
ItemIDs: itemIDs,
PaymentMethodID: paymentMethodID,
}
_, err = h.expenseService.CreateExpense(dto)
if err != nil {
slog.Error("failed to create expense", "error", err)
http.Error(w, "Failed to create expense.", http.StatusInternalServerError)
return
}
// Process linked items post-creation
for _, itemID := range itemIDs {
if itemAction == "delete" {
if err := h.listService.DeleteItem(itemID); err != nil {
slog.Error("failed to delete linked item", "error", err, "item_id", itemID)
}
} else {
if err := h.listService.CheckItem(itemID); err != nil {
slog.Error("failed to check linked item", "error", err, "item_id", itemID)
}
}
}
// If a redirect URL was provided (e.g. from the overview page), redirect instead of inline swap
if redirectURL := r.FormValue("redirect"); redirectURL != "" {
w.Header().Set("HX-Redirect", redirectURL)
w.WriteHeader(http.StatusOK)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = decimal.Zero
}
balance = balance.Sub(totalAllocated)
// Return the full paginated list for page 1 so the new expense appears
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, 1)
if err != nil {
slog.Error("failed to get paginated expenses after create", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Re-fetch tags (may have been auto-created)
refreshedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, totalAllocated, refreshedTags, 1, totalPages))
// OOB-swap the item selector with fresh data (items may have been deleted/checked)
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
if err != nil {
slog.Error("failed to refresh lists with items after create", "error", err, "space_id", spaceID)
return
}
ui.Render(w, r, expense.ItemSelectorSection(listsWithItems, true))
}
func (h *ExpenseHandler) 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 {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
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 == "" {
ui.RenderError(w, r, "All fields are required.", http.StatusUnprocessableEntity)
return
}
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil {
ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity)
return
}
amount := amountDecimal
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
ui.RenderError(w, r, "Invalid date format.", http.StatusUnprocessableEntity)
return
}
expenseType := model.ExpenseType(typeStr)
if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup {
ui.RenderError(w, r, "Invalid transaction type.", http.StatusUnprocessableEntity)
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
}
// Parse payment_method_id
var paymentMethodID *string
if pmid := r.FormValue("payment_method_id"); pmid != "" {
paymentMethodID = &pmid
}
dto := service.UpdateExpenseDTO{
ID: expenseID,
SpaceID: spaceID,
Description: description,
Amount: amount,
Type: expenseType,
Date: date,
TagIDs: finalTagIDs,
PaymentMethodID: paymentMethodID,
}
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})
methodsMap, _ := h.expenseService.GetPaymentMethodsByExpenseIDs([]string{updatedExpense.ID})
expWithTagsAndMethod := &model.ExpenseWithTagsAndMethod{
Expense: *updatedExpense,
Tags: tagsMap[updatedExpense.ID],
PaymentMethod: methodsMap[updatedExpense.ID],
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance after update", "error", err, "space_id", spaceID)
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = decimal.Zero
}
balance = balance.Sub(totalAllocated)
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
updatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods, updatedTags))
}
func (h *ExpenseHandler) 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
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance after delete", "error", err, "space_id", spaceID)
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = decimal.Zero
}
balance = balance.Sub(totalAllocated)
ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Expense deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *ExpenseHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
paginatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, paginatedTags, page, totalPages))
}
func (h *ExpenseHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
totalAllocated = decimal.Zero
}
balance = balance.Sub(totalAllocated)
ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, false))
}