From 45fcecdc040d77db93d4cc222e0c36ecfb6cd493 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 14 Mar 2026 16:27:45 +0000 Subject: [PATCH] chore: refactor --- internal/app/app.go | 4 - internal/handler/account_handler.go | 363 +++ internal/handler/auth.go | 65 +- internal/handler/budget_handler.go | 311 ++ internal/handler/expense_handler.go | 493 ++++ internal/handler/helpers.go | 49 + internal/handler/list_handler.go | 353 +++ internal/handler/method_handler.go | 143 + internal/handler/recurring_handler.go | 371 +++ internal/handler/space.go | 2521 +---------------- internal/handler/space_settings_handler.go | 300 ++ internal/handler/space_test.go | 93 +- internal/handler/tag_handler.go | 107 + internal/middleware/auth.go | 24 +- internal/middleware/redirect.go | 11 +- internal/middleware/utils.go | 9 +- internal/model/recurring_deposit.go | 24 - internal/repository/budget.go | 80 +- internal/repository/expense.go | 99 +- internal/repository/helpers.go | 18 + internal/repository/recurring_deposit.go | 105 - internal/repository/recurring_expense.go | 80 +- internal/routes/routes.go | 356 +-- internal/service/expense.go | 26 +- internal/service/money_account.go | 13 +- internal/service/pagination.go | 18 + internal/service/recurring_deposit.go | 284 -- internal/service/shopping_list.go | 13 +- .../moneyaccount/moneyaccount.templ | 399 +-- 29 files changed, 2865 insertions(+), 3867 deletions(-) create mode 100644 internal/handler/account_handler.go create mode 100644 internal/handler/budget_handler.go create mode 100644 internal/handler/expense_handler.go create mode 100644 internal/handler/helpers.go create mode 100644 internal/handler/list_handler.go create mode 100644 internal/handler/method_handler.go create mode 100644 internal/handler/recurring_handler.go create mode 100644 internal/handler/space_settings_handler.go create mode 100644 internal/handler/tag_handler.go delete mode 100644 internal/model/recurring_deposit.go create mode 100644 internal/repository/helpers.go delete mode 100644 internal/repository/recurring_deposit.go create mode 100644 internal/service/pagination.go delete mode 100644 internal/service/recurring_deposit.go diff --git a/internal/app/app.go b/internal/app/app.go index 226ab44..eb37361 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,7 +25,6 @@ type App struct { MoneyAccountService *service.MoneyAccountService PaymentMethodService *service.PaymentMethodService RecurringExpenseService *service.RecurringExpenseService - RecurringDepositService *service.RecurringDepositService BudgetService *service.BudgetService ReportService *service.ReportService LoanService *service.LoanService @@ -59,7 +58,6 @@ func New(cfg *config.Config) (*App, error) { moneyAccountRepository := repository.NewMoneyAccountRepository(database) paymentMethodRepository := repository.NewPaymentMethodRepository(database) recurringExpenseRepository := repository.NewRecurringExpenseRepository(database) - recurringDepositRepository := repository.NewRecurringDepositRepository(database) budgetRepository := repository.NewBudgetRepository(database) loanRepository := repository.NewLoanRepository(database) receiptRepository := repository.NewReceiptRepository(database) @@ -94,7 +92,6 @@ func New(cfg *config.Config) (*App, error) { moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository) paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository) recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository, spaceRepository) - recurringDepositService := service.NewRecurringDepositService(recurringDepositRepository, moneyAccountRepository, expenseService, profileRepository, spaceRepository) budgetService := service.NewBudgetService(budgetRepository) reportService := service.NewReportService(expenseRepository) loanService := service.NewLoanService(loanRepository, receiptRepository) @@ -116,7 +113,6 @@ func New(cfg *config.Config) (*App, error) { MoneyAccountService: moneyAccountService, PaymentMethodService: paymentMethodService, RecurringExpenseService: recurringExpenseService, - RecurringDepositService: recurringDepositService, BudgetService: budgetService, ReportService: reportService, LoanService: loanService, diff --git a/internal/handler/account_handler.go b/internal/handler/account_handler.go new file mode 100644 index 0000000..9982148 --- /dev/null +++ b/internal/handler/account_handler.go @@ -0,0 +1,363 @@ +package handler + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + + "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/moneyaccount" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type AccountHandler struct { + spaceService *service.SpaceService + accountService *service.MoneyAccountService + expenseService *service.ExpenseService +} + +func NewAccountHandler(ss *service.SpaceService, mas *service.MoneyAccountService, es *service.ExpenseService) *AccountHandler { + return &AccountHandler{ + spaceService: ss, + accountService: mas, + expenseService: es, + } +} + +func (h *AccountHandler) getAccountForSpace(w http.ResponseWriter, spaceID, accountID string) *model.MoneyAccount { + account, err := h.accountService.GetAccount(accountID) + if err != nil { + http.Error(w, "Account not found", http.StatusNotFound) + return nil + } + if account.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return account +} + +func (h *AccountHandler) AccountsPage(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 + } + + accounts, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + totalBalance, 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) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + availableBalance := totalBalance - totalAllocated + + transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) + if err != nil { + slog.Error("failed to get transfers", "error", err, "space_id", spaceID) + transfers = nil + totalPages = 1 + } + + ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, transfers, 1, totalPages)) +} + +func (h *AccountHandler) CreateAccount(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 + } + + name := r.FormValue("name") + if name == "" { + ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity) + return + } + + account, err := h.accountService.CreateAccount(service.CreateMoneyAccountDTO{ + SpaceID: spaceID, + Name: name, + CreatedBy: user.ID, + }) + if err != nil { + slog.Error("failed to create account", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + acctWithBalance := model.MoneyAccountWithBalance{ + MoneyAccount: *account, + BalanceCents: 0, + } + + ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) +} + +func (h *AccountHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + if h.getAccountForSpace(w, spaceID, accountID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + if name == "" { + ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity) + return + } + + updatedAccount, err := h.accountService.UpdateAccount(service.UpdateMoneyAccountDTO{ + ID: accountID, + Name: name, + }) + if err != nil { + slog.Error("failed to update account", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + balance, err := h.accountService.GetAccountBalance(accountID) + if err != nil { + slog.Error("failed to get account balance", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + acctWithBalance := model.MoneyAccountWithBalance{ + MoneyAccount: *updatedAccount, + BalanceCents: balance, + } + + ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) +} + +func (h *AccountHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + if h.getAccountForSpace(w, spaceID, accountID) == nil { + return + } + + err := h.accountService.DeleteAccount(accountID) + if err != nil { + slog.Error("failed to delete account", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Return updated balance summary via OOB swap + totalBalance, 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) + } + + ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Account deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + user := ctxkeys.User(r.Context()) + + if h.getAccountForSpace(w, spaceID, accountID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + amountStr := r.FormValue("amount") + direction := model.TransferDirection(r.FormValue("direction")) + note := r.FormValue("note") + + amountDecimal, err := decimal.NewFromString(amountStr) + if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) { + ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity) + return + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + // Calculate available space balance for deposit validation + totalBalance, 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) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + availableBalance := totalBalance - totalAllocated + + // Validate balance limits before creating transfer + if direction == model.TransferDirectionDeposit && amountCents > availableBalance { + ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0), http.StatusUnprocessableEntity) + return + } + + if direction == model.TransferDirectionWithdrawal { + acctBalance, err := h.accountService.GetAccountBalance(accountID) + if err != nil { + slog.Error("failed to get account balance", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if amountCents > acctBalance { + ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0), http.StatusUnprocessableEntity) + return + } + } + + _, err = h.accountService.CreateTransfer(service.CreateTransferDTO{ + AccountID: accountID, + Amount: amountCents, + Direction: direction, + Note: note, + CreatedBy: user.ID, + }, availableBalance) + if err != nil { + slog.Error("failed to create transfer", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Return updated account card + OOB balance summary + accountBalance, err := h.accountService.GetAccountBalance(accountID) + if err != nil { + slog.Error("failed to get account balance", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + account, _ := h.accountService.GetAccount(accountID) + acctWithBalance := model.MoneyAccountWithBalance{ + MoneyAccount: *account, + BalanceCents: accountBalance, + } + + // Recalculate available balance after transfer + totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID) + newAvailable := totalBalance - totalAllocated + + w.Header().Set("HX-Trigger", "transferSuccess") + ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) + ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, true)) + + transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) + ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) +} + +func (h *AccountHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + if h.getAccountForSpace(w, spaceID, accountID) == nil { + return + } + + transferID := r.PathValue("transferID") + err := h.accountService.DeleteTransfer(transferID) + if err != nil { + slog.Error("failed to delete transfer", "error", err, "transfer_id", transferID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Return updated account card + OOB balance summary + accountBalance, err := h.accountService.GetAccountBalance(accountID) + if err != nil { + slog.Error("failed to get account balance", "error", err, "account_id", accountID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + account, _ := h.accountService.GetAccount(accountID) + acctWithBalance := model.MoneyAccountWithBalance{ + MoneyAccount: *account, + BalanceCents: accountBalance, + } + + totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID) + totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID) + + ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) + ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) + + transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) + ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) + + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Transfer deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *AccountHandler) GetTransferHistory(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 + } + + transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, page) + if err != nil { + slog.Error("failed to get transfers", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, page, totalPages, false)) +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 2a4311b..6f4395d 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -2,11 +2,15 @@ package handler import ( "errors" + "fmt" "log/slog" "net/http" "strings" + "github.com/a-h/templ" + "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/toast" @@ -56,44 +60,9 @@ func (h *authHandler) LoginWithPassword(w http.ResponseWriter, r *http.Request) return } - jwtToken, err := h.authService.GenerateJWT(user) - if err != nil { - slog.Error("failed to generate JWT", "error", err, "user_id", user.ID) - ui.Render(w, r, pages.AuthPassword("An error occurred. Please try again.")) + if err := h.completeLogin(w, r, user, pages.AuthPassword); err != nil { return } - - h.authService.SetJWTCookie(w, jwtToken) - - // Check for pending invite - inviteCookie, err := r.Cookie("pending_invite") - if err == nil && inviteCookie.Value != "" { - spaceID, err := h.inviteService.AcceptInvite(inviteCookie.Value, user.ID) - if err != nil { - slog.Error("failed to process pending invite", "error", err, "token", inviteCookie.Value) - } else { - slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID) - http.SetCookie(w, &http.Cookie{ - Name: "pending_invite", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - }) - } - } - - needsOnboarding, err := h.authService.NeedsOnboarding(user.ID) - if err != nil { - slog.Warn("failed to check onboarding status", "error", err, "user_id", user.ID) - } - - if needsOnboarding { - http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) - return - } - - http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) } func (h *authHandler) Logout(w http.ResponseWriter, r *http.Request) { @@ -145,11 +114,22 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { return } + if err := h.completeLogin(w, r, user, pages.Auth); err != nil { + return + } + + slog.Info("user logged via magic link", "user_id", user.ID, "email", user.Email) +} + +// completeLogin handles the post-authentication flow: JWT generation, +// pending invite processing, onboarding check, and redirect. +// Returns an error if the response was already written (caller should return early). +func (h *authHandler) completeLogin(w http.ResponseWriter, r *http.Request, user *model.User, renderError func(string) templ.Component) error { jwtToken, err := h.authService.GenerateJWT(user) if err != nil { slog.Error("failed to generate JWT", "error", err, "user_id", user.ID) - ui.Render(w, r, pages.Auth("An error occurred. Please try again.")) - return + ui.Render(w, r, renderError("An error occurred. Please try again.")) + return fmt.Errorf("jwt generation failed") } h.authService.SetJWTCookie(w, jwtToken) @@ -160,10 +140,8 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { spaceID, err := h.inviteService.AcceptInvite(inviteCookie.Value, user.ID) if err != nil { slog.Error("failed to process pending invite", "error", err, "token", inviteCookie.Value) - // Don't fail the login, just maybe notify user? } else { slog.Info("accepted pending invite", "user_id", user.ID, "space_id", spaceID) - // Clear cookie http.SetCookie(w, &http.Cookie{ Name: "pending_invite", Value: "", @@ -171,8 +149,6 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { MaxAge: -1, HttpOnly: true, }) - // If we want to redirect to the space immediately, we can. - // But check onboarding first. } } @@ -182,13 +158,12 @@ func (h *authHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) { } if needsOnboarding { - slog.Info("new user needs onboarding", "user_id", user.ID, "email", user.Email) http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) - return + return fmt.Errorf("redirected to onboarding") } - slog.Info("user logged via magic link", "user_id", user.ID, "email", user.Email) http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) + return nil } func (h *authHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/budget_handler.go b/internal/handler/budget_handler.go new file mode 100644 index 0000000..8095267 --- /dev/null +++ b/internal/handler/budget_handler.go @@ -0,0 +1,311 @@ +package handler + +import ( + "log/slog" + "net/http" + "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/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type BudgetHandler struct { + spaceService *service.SpaceService + budgetService *service.BudgetService + tagService *service.TagService + reportService *service.ReportService +} + +func NewBudgetHandler(ss *service.SpaceService, bs *service.BudgetService, ts *service.TagService, rps *service.ReportService) *BudgetHandler { + return &BudgetHandler{ + spaceService: ss, + budgetService: bs, + tagService: ts, + reportService: rps, + } +} + +func (h *BudgetHandler) getBudgetForSpace(w http.ResponseWriter, spaceID, budgetID string) *model.Budget { + budget, err := h.budgetService.GetBudget(budgetID) + if err != nil { + http.Error(w, "Budget not found", http.StatusNotFound) + return nil + } + if budget.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return budget +} + +func (h *BudgetHandler) BudgetsPage(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 + } + + tags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID) + if err != nil { + slog.Error("failed to get budgets", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceBudgetsPage(space, budgets, tags)) +} + +func (h *BudgetHandler) CreateBudget(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 + } + + tagNames := r.Form["tags"] + amountStr := r.FormValue("amount") + periodStr := r.FormValue("period") + startDateStr := r.FormValue("start_date") + endDateStr := r.FormValue("end_date") + + if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" { + ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) + return + } + + tagIDs, err := processTagNames(h.tagService, spaceID, tagNames) + if err != nil { + slog.Error("failed to process tag names", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if len(tagIDs) == 0 { + ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity) + return + } + + amountDecimal, err := decimal.NewFromString(amountStr) + if err != nil { + ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) + return + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) + return + } + + var endDate *time.Time + if endDateStr != "" { + ed, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) + return + } + endDate = &ed + } + + _, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{ + SpaceID: spaceID, + TagIDs: tagIDs, + Amount: amountCents, + Period: model.BudgetPeriod(periodStr), + StartDate: startDate, + EndDate: endDate, + CreatedBy: user.ID, + }) + if err != nil { + slog.Error("failed to create budget", "error", err) + http.Error(w, "Failed to create budget.", http.StatusInternalServerError) + return + } + + // Refresh the full budgets list + tags, _ := h.tagService.GetTagsForSpace(spaceID) + budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID) + ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) +} + +func (h *BudgetHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + budgetID := r.PathValue("budgetID") + + if h.getBudgetForSpace(w, spaceID, budgetID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + tagNames := r.Form["tags"] + amountStr := r.FormValue("amount") + periodStr := r.FormValue("period") + startDateStr := r.FormValue("start_date") + endDateStr := r.FormValue("end_date") + + if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" { + ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) + return + } + + tagIDs, err := processTagNames(h.tagService, spaceID, tagNames) + if err != nil { + slog.Error("failed to process tag names", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if len(tagIDs) == 0 { + ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity) + return + } + + amountDecimal, err := decimal.NewFromString(amountStr) + if err != nil { + ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) + return + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) + return + } + + var endDate *time.Time + if endDateStr != "" { + ed, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) + return + } + endDate = &ed + } + + _, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{ + ID: budgetID, + TagIDs: tagIDs, + Amount: amountCents, + Period: model.BudgetPeriod(periodStr), + StartDate: startDate, + EndDate: endDate, + }) + if err != nil { + slog.Error("failed to update budget", "error", err) + http.Error(w, "Failed to update budget.", http.StatusInternalServerError) + return + } + + // Refresh the full budgets list + tags, _ := h.tagService.GetTagsForSpace(spaceID) + budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID) + ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) +} + +func (h *BudgetHandler) DeleteBudget(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + budgetID := r.PathValue("budgetID") + + if h.getBudgetForSpace(w, spaceID, budgetID) == nil { + return + } + + if err := h.budgetService.DeleteBudget(budgetID); err != nil { + slog.Error("failed to delete budget", "error", err, "budget_id", budgetID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Budget deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *BudgetHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + tags, _ := h.tagService.GetTagsForSpace(spaceID) + budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID) + if err != nil { + slog.Error("failed to get budgets", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) +} + +func (h *BudgetHandler) GetReportCharts(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + rangeKey := r.URL.Query().Get("range") + now := time.Now() + presets := service.GetPresetDateRanges(now) + + var from, to time.Time + activeRange := "this_month" + + if rangeKey == "custom" { + fromStr := r.URL.Query().Get("from") + toStr := r.URL.Query().Get("to") + var err error + from, err = time.Parse("2006-01-02", fromStr) + if err != nil { + from = presets[0].From + } + to, err = time.Parse("2006-01-02", toStr) + if err != nil { + to = presets[0].To + } + activeRange = "custom" + } else { + for _, p := range presets { + if p.Key == rangeKey { + from = p.From + to = p.To + activeRange = p.Key + break + } + } + if from.IsZero() { + from = presets[0].From + to = presets[0].To + } + } + + report, err := h.reportService.GetSpendingReport(spaceID, from, to) + if err != nil { + slog.Error("failed to get report charts", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.ReportCharts(spaceID, report, from, to, presets, activeRange)) +} diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go new file mode 100644 index 0000000..9948894 --- /dev/null +++ b/internal/handler/expense_handler.go @@ -0,0 +1,493 @@ +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 = 0 + } + balance -= 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 + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + 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: amountCents, + 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 = 0 + } + balance -= 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 + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + 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: amountCents, + 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 = 0 + } + balance -= 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 = 0 + } + balance -= 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 = 0 + } + balance -= totalAllocated + + ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, false)) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..90fe175 --- /dev/null +++ b/internal/handler/helpers.go @@ -0,0 +1,49 @@ +package handler + +import ( + "log/slog" + + "git.juancwu.dev/juancwu/budgit/internal/service" +) + +// processTagNames normalizes tag names, deduplicates them, and resolves them +// to tag IDs. Tags that don't exist are auto-created. +func processTagNames(tagService *service.TagService, spaceID string, tagNames []string) ([]string, error) { + existingTags, err := tagService.GetTagsForSpace(spaceID) + if err != nil { + return nil, err + } + + 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 { + newTag, err := tagService.CreateTag(spaceID, tagName, nil) + if err != nil { + slog.Error("failed to create new tag", "error", err, "tag_name", tagName) + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + existingTagsMap[tagName] = newTag.ID + } + processedTags[tagName] = true + } + + return finalTagIDs, nil +} diff --git a/internal/handler/list_handler.go b/internal/handler/list_handler.go new file mode 100644 index 0000000..4de5d09 --- /dev/null +++ b/internal/handler/list_handler.go @@ -0,0 +1,353 @@ +package handler + +import ( + "log/slog" + "net/http" + "strconv" + + "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/shoppinglist" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type ListHandler struct { + spaceService *service.SpaceService + listService *service.ShoppingListService +} + +func NewListHandler(ss *service.SpaceService, sls *service.ShoppingListService) *ListHandler { + return &ListHandler{ + spaceService: ss, + listService: sls, + } +} + +// 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 *ListHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList { + list, err := h.listService.GetList(listID) + if err != nil { + http.Error(w, "List not found", http.StatusNotFound) + return nil + } + if list.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return list +} + +func (h *ListHandler) ListsPage(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 + } + + cards, err := h.buildListCards(spaceID) + if err != nil { + slog.Error("failed to build list cards", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceListsPage(space, cards)) +} + +func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + err := r.ParseForm() + if err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + if name == "" { + // handle error - maybe return a toast + ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity) + return + } + + newList, err := h.listService.CreateList(spaceID, name) + if err != nil { + slog.Error("failed to create list", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, shoppinglist.ListCard(spaceID, newList, nil, 1, 1)) +} + +func (h *ListHandler) UpdateList(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + if name == "" { + ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity) + return + } + + updatedList, err := h.listService.UpdateList(listID, name) + if err != nil { + slog.Error("failed to update list", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if r.URL.Query().Get("from") == "card" { + ui.Render(w, r, shoppinglist.ListCardHeader(spaceID, updatedList)) + } else { + ui.Render(w, r, shoppinglist.ListNameHeader(spaceID, updatedList)) + } +} + +func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + err := h.listService.DeleteList(listID) + if err != nil { + slog.Error("failed to delete list", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if r.URL.Query().Get("from") != "card" { + w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists") + } + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "List deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *ListHandler) ListPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + list := h.getListForSpace(w, spaceID, listID) + if list == nil { + return + } + + items, err := h.listService.GetItemsForList(listID) + if err != nil { + slog.Error("failed to get items for list", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceListDetailPage(space, list, items)) +} + +func (h *ListHandler) AddItemToList(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + user := ctxkeys.User(r.Context()) + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + if name == "" { + ui.RenderError(w, r, "Item name cannot be empty", http.StatusUnprocessableEntity) + return + } + + newItem, err := h.listService.AddItemToList(listID, name, user.ID) + if err != nil { + slog.Error("failed to add item to list", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, shoppinglist.ItemDetail(spaceID, newItem)) +} + +func (h *ListHandler) ToggleItem(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + itemID := r.PathValue("itemID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + item, err := h.listService.GetItem(itemID) + if err != nil { + slog.Error("failed to get item", "error", err, "item_id", itemID) + http.Error(w, "Item not found", http.StatusNotFound) + return + } + + if item.ListID != listID { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + updatedItem, err := h.listService.UpdateItem(itemID, item.Name, !item.IsChecked) + if err != nil { + slog.Error("failed to toggle item", "error", err, "item_id", itemID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + if r.URL.Query().Get("from") == "card" { + ui.Render(w, r, shoppinglist.CardItemDetail(spaceID, updatedItem)) + } else { + ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem)) + } +} + +func (h *ListHandler) DeleteItem(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + itemID := r.PathValue("itemID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + item, err := h.listService.GetItem(itemID) + if err != nil { + slog.Error("failed to get item", "error", err, "item_id", itemID) + http.Error(w, "Item not found", http.StatusNotFound) + return + } + + if item.ListID != listID { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + err = h.listService.DeleteItem(itemID) + if err != nil { + slog.Error("failed to delete item", "error", err, "item_id", itemID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Item deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *ListHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + items, err := h.listService.GetItemsForList(listID) + if err != nil { + slog.Error("failed to get items", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.ShoppingListItems(spaceID, items)) +} + +func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + + cards, err := h.buildListCards(spaceID) + if err != nil { + slog.Error("failed to build list cards", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.ListsContainer(spaceID, cards)) +} + +func (h *ListHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + listID := r.PathValue("listID") + + if h.getListForSpace(w, spaceID, listID) == nil { + return + } + + page := 1 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + + items, totalPages, err := h.listService.GetItemsForListPaginated(listID, page) + if err != nil { + slog.Error("failed to get paginated items", "error", err, "list_id", listID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) +} + +func (h *ListHandler) buildListCards(spaceID string) ([]model.ListCardData, error) { + lists, err := h.listService.GetListsForSpace(spaceID) + if err != nil { + return nil, err + } + + cards := make([]model.ListCardData, len(lists)) + for i, list := range lists { + items, totalPages, err := h.listService.GetItemsForListPaginated(list.ID, 1) + if err != nil { + return nil, err + } + cards[i] = model.ListCardData{ + List: list, + Items: items, + CurrentPage: 1, + TotalPages: totalPages, + } + } + + return cards, nil +} diff --git a/internal/handler/method_handler.go b/internal/handler/method_handler.go new file mode 100644 index 0000000..5a0b1bd --- /dev/null +++ b/internal/handler/method_handler.go @@ -0,0 +1,143 @@ +package handler + +import ( + "log/slog" + "net/http" + + "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/paymentmethod" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type MethodHandler struct { + spaceService *service.SpaceService + methodService *service.PaymentMethodService +} + +func NewMethodHandler(ss *service.SpaceService, pms *service.PaymentMethodService) *MethodHandler { + return &MethodHandler{ + spaceService: ss, + methodService: pms, + } +} + +func (h *MethodHandler) getMethodForSpace(w http.ResponseWriter, spaceID, methodID string) *model.PaymentMethod { + method, err := h.methodService.GetMethod(methodID) + if err != nil { + http.Error(w, "Payment method not found", http.StatusNotFound) + return nil + } + if method.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return method +} + +func (h *MethodHandler) PaymentMethodsPage(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 + } + + methods, err := h.methodService.GetMethodsForSpace(spaceID) + if err != nil { + slog.Error("failed to get payment methods for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpacePaymentMethodsPage(space, methods)) +} + +func (h *MethodHandler) CreatePaymentMethod(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 + } + + name := r.FormValue("name") + methodType := model.PaymentMethodType(r.FormValue("type")) + lastFour := r.FormValue("last_four") + + method, err := h.methodService.CreateMethod(service.CreatePaymentMethodDTO{ + SpaceID: spaceID, + Name: name, + Type: methodType, + LastFour: lastFour, + CreatedBy: user.ID, + }) + if err != nil { + slog.Error("failed to create payment method", "error", err, "space_id", spaceID) + ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity) + return + } + + ui.Render(w, r, paymentmethod.MethodItem(spaceID, method)) +} + +func (h *MethodHandler) UpdatePaymentMethod(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + methodID := r.PathValue("methodID") + + if h.getMethodForSpace(w, spaceID, methodID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + methodType := model.PaymentMethodType(r.FormValue("type")) + lastFour := r.FormValue("last_four") + + updatedMethod, err := h.methodService.UpdateMethod(service.UpdatePaymentMethodDTO{ + ID: methodID, + Name: name, + Type: methodType, + LastFour: lastFour, + }) + if err != nil { + slog.Error("failed to update payment method", "error", err, "method_id", methodID) + ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity) + return + } + + ui.Render(w, r, paymentmethod.MethodItem(spaceID, updatedMethod)) +} + +func (h *MethodHandler) DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + methodID := r.PathValue("methodID") + + if h.getMethodForSpace(w, spaceID, methodID) == nil { + return + } + + err := h.methodService.DeleteMethod(methodID) + if err != nil { + slog.Error("failed to delete payment method", "error", err, "method_id", methodID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Payment method deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} diff --git a/internal/handler/recurring_handler.go b/internal/handler/recurring_handler.go new file mode 100644 index 0000000..70d4eae --- /dev/null +++ b/internal/handler/recurring_handler.go @@ -0,0 +1,371 @@ +package handler + +import ( + "log/slog" + "net/http" + "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/recurring" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type RecurringHandler struct { + spaceService *service.SpaceService + recurringService *service.RecurringExpenseService + tagService *service.TagService + methodService *service.PaymentMethodService +} + +func NewRecurringHandler(ss *service.SpaceService, rs *service.RecurringExpenseService, ts *service.TagService, pms *service.PaymentMethodService) *RecurringHandler { + return &RecurringHandler{ + spaceService: ss, + recurringService: rs, + tagService: ts, + methodService: pms, + } +} + +func (h *RecurringHandler) getRecurringForSpace(w http.ResponseWriter, spaceID, recurringID string) *model.RecurringExpense { + re, err := h.recurringService.GetRecurringExpense(recurringID) + if err != nil { + http.Error(w, "Recurring expense not found", http.StatusNotFound) + return nil + } + if re.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return re +} + +func (h *RecurringHandler) RecurringExpensesPage(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 + } + + // Lazy check: process any due recurrences for this space + h.recurringService.ProcessDueRecurrencesForSpace(spaceID, time.Now()) + + recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) + if err != nil { + slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + tags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags", "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.SpaceRecurringPage(space, recs, tags, methods)) +} + +func (h *RecurringHandler) CreateRecurringExpense(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 + } + + description := r.FormValue("description") + amountStr := r.FormValue("amount") + typeStr := r.FormValue("type") + frequencyStr := r.FormValue("frequency") + startDateStr := r.FormValue("start_date") + endDateStr := r.FormValue("end_date") + tagNames := r.Form["tags"] + + if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" { + ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) + return + } + + amountDecimal, err := decimal.NewFromString(amountStr) + if err != nil { + ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity) + return + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid start date format.", http.StatusUnprocessableEntity) + return + } + + var endDate *time.Time + if endDateStr != "" { + ed, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid end date format.", http.StatusUnprocessableEntity) + return + } + endDate = &ed + } + + expenseType := model.ExpenseType(typeStr) + if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { + ui.RenderError(w, r, "Invalid transaction type.", http.StatusUnprocessableEntity) + return + } + + frequency := model.Frequency(frequencyStr) + + // Tag processing + existingTags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags", "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 tag", "error", err, "tag_name", tagName) + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + existingTagsMap[tagName] = newTag.ID + } + processedTags[tagName] = true + } + + var paymentMethodID *string + if pmid := r.FormValue("payment_method_id"); pmid != "" { + paymentMethodID = &pmid + } + + re, err := h.recurringService.CreateRecurringExpense(service.CreateRecurringExpenseDTO{ + SpaceID: spaceID, + UserID: user.ID, + Description: description, + Amount: amountCents, + Type: expenseType, + PaymentMethodID: paymentMethodID, + Frequency: frequency, + StartDate: startDate, + EndDate: endDate, + TagIDs: finalTagIDs, + }) + if err != nil { + slog.Error("failed to create recurring expense", "error", err) + http.Error(w, "Failed to create recurring expense.", http.StatusInternalServerError) + return + } + + // Fetch tags/method for the response + spaceTags, _ := h.tagService.GetTagsForSpace(spaceID) + tagsMap, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) + for _, item := range tagsMap { + if item.ID == re.ID { + ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil, spaceTags)) + return + } + } + + // Fallback: render without tags + ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil, spaceTags)) +} + +func (h *RecurringHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + recurringID := r.PathValue("recurringID") + + if h.getRecurringForSpace(w, spaceID, recurringID) == 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") + frequencyStr := r.FormValue("frequency") + startDateStr := r.FormValue("start_date") + endDateStr := r.FormValue("end_date") + tagNames := r.Form["tags"] + + if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" { + ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) + return + } + + amountDecimal, err := decimal.NewFromString(amountStr) + if err != nil { + ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) + return + } + amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) + + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) + return + } + + var endDate *time.Time + if endDateStr != "" { + ed, err := time.Parse("2006-01-02", endDateStr) + if err != nil { + ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) + return + } + endDate = &ed + } + + // Tag processing + existingTags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + 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 { + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + } + processedTags[tagName] = true + } + + var paymentMethodID *string + if pmid := r.FormValue("payment_method_id"); pmid != "" { + paymentMethodID = &pmid + } + + updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{ + ID: recurringID, + Description: description, + Amount: amountCents, + Type: model.ExpenseType(typeStr), + PaymentMethodID: paymentMethodID, + Frequency: model.Frequency(frequencyStr), + StartDate: startDate, + EndDate: endDate, + TagIDs: finalTagIDs, + }) + if err != nil { + slog.Error("failed to update recurring expense", "error", err) + http.Error(w, "Failed to update.", http.StatusInternalServerError) + return + } + + // Build response with tags/method + updateSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID) + tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) + for _, item := range tagsMapResult { + if item.ID == updated.ID { + methods, _ := h.methodService.GetMethodsForSpace(spaceID) + ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, updateSpaceTags)) + return + } + } + + ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, updateSpaceTags)) +} + +func (h *RecurringHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + recurringID := r.PathValue("recurringID") + + if h.getRecurringForSpace(w, spaceID, recurringID) == nil { + return + } + + if err := h.recurringService.DeleteRecurringExpense(recurringID); err != nil { + slog.Error("failed to delete recurring expense", "error", err, "recurring_id", recurringID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Recurring expense deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *RecurringHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + recurringID := r.PathValue("recurringID") + + if h.getRecurringForSpace(w, spaceID, recurringID) == nil { + return + } + + updated, err := h.recurringService.ToggleRecurringExpense(recurringID) + if err != nil { + slog.Error("failed to toggle recurring expense", "error", err, "recurring_id", recurringID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + toggleSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID) + tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) + for _, item := range tagsMapResult { + if item.ID == updated.ID { + methods, _ := h.methodService.GetMethodsForSpace(spaceID) + ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, toggleSpaceTags)) + return + } + } + + ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, toggleSpaceTags)) +} diff --git a/internal/handler/space.go b/internal/handler/space.go index e514daf..56473a9 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -4,9 +4,6 @@ import ( "fmt" "log/slog" "net/http" - "strconv" - - "github.com/shopspring/decimal" "strings" "time" @@ -14,46 +11,48 @@ import ( "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/moneyaccount" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/tag" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" "git.juancwu.dev/juancwu/budgit/internal/ui/pages" ) type SpaceHandler struct { spaceService *service.SpaceService - tagService *service.TagService - listService *service.ShoppingListService expenseService *service.ExpenseService - inviteService *service.InviteService accountService *service.MoneyAccountService - methodService *service.PaymentMethodService - recurringService *service.RecurringExpenseService - recurringDepositService *service.RecurringDepositService - budgetService *service.BudgetService reportService *service.ReportService + budgetService *service.BudgetService + recurringService *service.RecurringExpenseService + listService *service.ShoppingListService + tagService *service.TagService + methodService *service.PaymentMethodService loanService *service.LoanService receiptService *service.ReceiptService recurringReceiptService *service.RecurringReceiptService } -func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService, pms *service.PaymentMethodService, rs *service.RecurringExpenseService, rds *service.RecurringDepositService, bs *service.BudgetService, rps *service.ReportService, ls *service.LoanService, rcs *service.ReceiptService, rrs *service.RecurringReceiptService) *SpaceHandler { +func NewSpaceHandler( + ss *service.SpaceService, + es *service.ExpenseService, + mas *service.MoneyAccountService, + rps *service.ReportService, + bs *service.BudgetService, + rs *service.RecurringExpenseService, + sls *service.ShoppingListService, + ts *service.TagService, + pms *service.PaymentMethodService, + ls *service.LoanService, + rcs *service.ReceiptService, + rrs *service.RecurringReceiptService, +) *SpaceHandler { return &SpaceHandler{ spaceService: ss, - tagService: ts, - listService: sls, expenseService: es, - inviteService: is, accountService: mas, - methodService: pms, - recurringService: rs, - recurringDepositService: rds, - budgetService: bs, reportService: rps, + budgetService: bs, + recurringService: rs, + listService: sls, + tagService: ts, + methodService: pms, loanService: ls, receiptService: rcs, recurringReceiptService: rrs, @@ -94,49 +93,6 @@ func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// 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 { - list, err := h.listService.GetList(listID) - if err != nil { - http.Error(w, "List not found", http.StatusNotFound) - return nil - } - if list.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return list -} - -// getTagForSpace fetches a tag and verifies it belongs to the given space. -func (h *SpaceHandler) getTagForSpace(w http.ResponseWriter, spaceID, tagID string) *model.Tag { - tag, err := h.tagService.GetTagByID(tagID) - if err != nil { - http.Error(w, "Tag not found", http.StatusNotFound) - return nil - } - if tag.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return tag -} - func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") space, err := h.spaceService.GetSpace(spaceID) @@ -246,2435 +202,6 @@ func (h *SpaceHandler) ReportsPage(w http.ResponseWriter, r *http.Request) { ui.Render(w, r, pages.SpaceReportsPage(space, report, presets, "this_month")) } -func (h *SpaceHandler) ListsPage(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 - } - - cards, err := h.buildListCards(spaceID) - if err != nil { - slog.Error("failed to build list cards", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.SpaceListsPage(space, cards)) -} - -func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - - err := r.ParseForm() - if err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - if name == "" { - // handle error - maybe return a toast - ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity) - return - } - - newList, err := h.listService.CreateList(spaceID, name) - if err != nil { - slog.Error("failed to create list", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, shoppinglist.ListCard(spaceID, newList, nil, 1, 1)) -} - -func (h *SpaceHandler) UpdateList(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - if name == "" { - ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity) - return - } - - updatedList, err := h.listService.UpdateList(listID, name) - if err != nil { - slog.Error("failed to update list", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if r.URL.Query().Get("from") == "card" { - ui.Render(w, r, shoppinglist.ListCardHeader(spaceID, updatedList)) - } else { - ui.Render(w, r, shoppinglist.ListNameHeader(spaceID, updatedList)) - } -} - -func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - err := h.listService.DeleteList(listID) - if err != nil { - slog.Error("failed to delete list", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if r.URL.Query().Get("from") != "card" { - w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists") - } - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "List deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) ListPage(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - list := h.getListForSpace(w, spaceID, listID) - if list == nil { - return - } - - items, err := h.listService.GetItemsForList(listID) - if err != nil { - slog.Error("failed to get items for list", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.SpaceListDetailPage(space, list, items)) -} - -func (h *SpaceHandler) AddItemToList(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - user := ctxkeys.User(r.Context()) - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - if name == "" { - ui.RenderError(w, r, "Item name cannot be empty", http.StatusUnprocessableEntity) - return - } - - newItem, err := h.listService.AddItemToList(listID, name, user.ID) - if err != nil { - slog.Error("failed to add item to list", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, shoppinglist.ItemDetail(spaceID, newItem)) -} - -func (h *SpaceHandler) ToggleItem(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - itemID := r.PathValue("itemID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - item, err := h.listService.GetItem(itemID) - if err != nil { - slog.Error("failed to get item", "error", err, "item_id", itemID) - http.Error(w, "Item not found", http.StatusNotFound) - return - } - - if item.ListID != listID { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - - updatedItem, err := h.listService.UpdateItem(itemID, item.Name, !item.IsChecked) - if err != nil { - slog.Error("failed to toggle item", "error", err, "item_id", itemID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if r.URL.Query().Get("from") == "card" { - ui.Render(w, r, shoppinglist.CardItemDetail(spaceID, updatedItem)) - } else { - ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem)) - } -} - -func (h *SpaceHandler) DeleteItem(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - itemID := r.PathValue("itemID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - item, err := h.listService.GetItem(itemID) - if err != nil { - slog.Error("failed to get item", "error", err, "item_id", itemID) - http.Error(w, "Item not found", http.StatusNotFound) - return - } - - if item.ListID != listID { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - - err = h.listService.DeleteItem(itemID) - if err != nil { - slog.Error("failed to delete item", "error", err, "item_id", itemID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Item deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) TagsPage(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 - } - - 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 - } - - ui.Render(w, r, pages.SpaceTagsPage(space, tags)) -} - -func (h *SpaceHandler) CreateTag(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - color := r.FormValue("color") // color is optional - - var colorPtr *string - if color != "" { - colorPtr = &color - } - - newTag, err := h.tagService.CreateTag(spaceID, name, colorPtr) - if err != nil { - slog.Error("failed to create tag", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, tag.Tag(newTag)) -} - -func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - tagID := r.PathValue("tagID") - - if h.getTagForSpace(w, spaceID, tagID) == nil { - return - } - - err := h.tagService.DeleteTag(tagID) - if err != nil { - slog.Error("failed to delete tag", "error", err, "tag_id", tagID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Tag deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) 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 = 0 - } - balance -= 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 *SpaceHandler) 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 - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - 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: amountCents, - 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 = 0 - } - balance -= 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 *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 { - 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 - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - 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: amountCents, - 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 = 0 - } - balance -= 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 *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 - } - - 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 = 0 - } - balance -= 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 *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - email := r.FormValue("email") - if email == "" { - ui.RenderError(w, r, "Email is required", http.StatusUnprocessableEntity) - return - } - - _, err = h.inviteService.CreateInvite(spaceID, user.ID, email) - if err != nil { - slog.Error("failed to create invite", "error", err, "space_id", spaceID) - http.Error(w, "Failed to create invite", http.StatusInternalServerError) - return - } - - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Invitation sent", - Description: "An email has been sent to " + email, - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { - token := r.PathValue("token") - user := ctxkeys.User(r.Context()) - - if user != nil { - spaceID, err := h.inviteService.AcceptInvite(token, user.ID) - if err != nil { - slog.Error("failed to accept invite", "error", err, "token", token) - ui.RenderError(w, r, "Failed to join space: "+err.Error(), http.StatusUnprocessableEntity) - return - } - - http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther) - return - } - - // Not logged in: set cookie and redirect to auth - http.SetCookie(w, &http.Cookie{ - Name: "pending_invite", - Value: token, - Path: "/", - Expires: time.Now().Add(1 * time.Hour), - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect) -} - -func (h *SpaceHandler) 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 = 0 - } - balance -= totalAllocated - - ui.Render(w, r, expense.BalanceCard(spaceID, balance, totalAllocated, false)) -} - -func (h *SpaceHandler) 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 *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - items, err := h.listService.GetItemsForList(listID) - if err != nil { - slog.Error("failed to get items", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.ShoppingListItems(spaceID, items)) -} - -func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - - cards, err := h.buildListCards(spaceID) - if err != nil { - slog.Error("failed to build list cards", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.ListsContainer(spaceID, cards)) -} - -func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - listID := r.PathValue("listID") - - if h.getListForSpace(w, spaceID, listID) == nil { - return - } - - page := 1 - if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { - page = p - } - - items, totalPages, err := h.listService.GetItemsForListPaginated(listID, page) - if err != nil { - slog.Error("failed to get paginated items", "error", err, "list_id", listID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) -} - -func (h *SpaceHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - slog.Error("failed to get space", "error", err, "space_id", spaceID) - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - members, err := h.spaceService.GetMembers(spaceID) - if err != nil { - slog.Error("failed to get members", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - isOwner := space.OwnerID == user.ID - - var pendingInvites []*model.SpaceInvitation - if isOwner { - pendingInvites, err = h.inviteService.GetPendingInvites(spaceID) - if err != nil { - slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - } - - ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID)) -} - -func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - if name == "" { - ui.RenderError(w, r, "Name is required", http.StatusUnprocessableEntity) - return - } - - if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil { - slog.Error("failed to update space name", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.Header().Set("HX-Refresh", "true") - w.WriteHeader(http.StatusOK) -} - -func (h *SpaceHandler) UpdateSpaceTimezone(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - tz := r.FormValue("timezone") - if tz == "" { - ui.RenderError(w, r, "Timezone is required", http.StatusUnprocessableEntity) - return - } - - if err := h.spaceService.UpdateSpaceTimezone(spaceID, tz); err != nil { - if err == service.ErrInvalidTimezone { - ui.RenderError(w, r, "Invalid timezone", http.StatusUnprocessableEntity) - return - } - slog.Error("failed to update space timezone", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.Header().Set("HX-Refresh", "true") - w.WriteHeader(http.StatusOK) -} - -func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - userID := r.PathValue("userID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - if userID == user.ID { - ui.RenderError(w, r, "Cannot remove yourself", http.StatusUnprocessableEntity) - return - } - - if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { - slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Member removed", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - token := r.PathValue("token") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - if err := h.inviteService.CancelInvite(token); err != nil { - slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Invitation cancelled", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - user := ctxkeys.User(r.Context()) - - space, err := h.spaceService.GetSpace(spaceID) - if err != nil { - http.Error(w, "Space not found", http.StatusNotFound) - return - } - - if space.OwnerID != user.ID { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - pendingInvites, err := h.inviteService.GetPendingInvites(spaceID) - if err != nil { - slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites)) -} - -// --- Money Accounts --- - -func (h *SpaceHandler) getAccountForSpace(w http.ResponseWriter, spaceID, accountID string) *model.MoneyAccount { - account, err := h.accountService.GetAccount(accountID) - if err != nil { - http.Error(w, "Account not found", http.StatusNotFound) - return nil - } - if account.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return account -} - -func (h *SpaceHandler) AccountsPage(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 - } - - accounts, err := h.accountService.GetAccountsForSpace(spaceID) - if err != nil { - slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - totalBalance, 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) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - availableBalance := totalBalance - totalAllocated - - transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) - if err != nil { - slog.Error("failed to get transfers", "error", err, "space_id", spaceID) - transfers = nil - totalPages = 1 - } - - ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, transfers, 1, totalPages)) -} - -func (h *SpaceHandler) CreateAccount(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 - } - - name := r.FormValue("name") - if name == "" { - ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity) - return - } - - account, err := h.accountService.CreateAccount(service.CreateMoneyAccountDTO{ - SpaceID: spaceID, - Name: name, - CreatedBy: user.ID, - }) - if err != nil { - slog.Error("failed to create account", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - acctWithBalance := model.MoneyAccountWithBalance{ - MoneyAccount: *account, - BalanceCents: 0, - } - - ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) -} - -func (h *SpaceHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - accountID := r.PathValue("accountID") - - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - if name == "" { - ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity) - return - } - - updatedAccount, err := h.accountService.UpdateAccount(service.UpdateMoneyAccountDTO{ - ID: accountID, - Name: name, - }) - if err != nil { - slog.Error("failed to update account", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - balance, err := h.accountService.GetAccountBalance(accountID) - if err != nil { - slog.Error("failed to get account balance", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - acctWithBalance := model.MoneyAccountWithBalance{ - MoneyAccount: *updatedAccount, - BalanceCents: balance, - } - - ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance)) -} - -func (h *SpaceHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - accountID := r.PathValue("accountID") - - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - err := h.accountService.DeleteAccount(accountID) - if err != nil { - slog.Error("failed to delete account", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // Return updated balance summary via OOB swap - totalBalance, 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) - } - - ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Account deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - accountID := r.PathValue("accountID") - user := ctxkeys.User(r.Context()) - - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - amountStr := r.FormValue("amount") - direction := model.TransferDirection(r.FormValue("direction")) - note := r.FormValue("note") - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) { - ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - // Calculate available space balance for deposit validation - totalBalance, 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) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - availableBalance := totalBalance - totalAllocated - - // Validate balance limits before creating transfer - if direction == model.TransferDirectionDeposit && amountCents > availableBalance { - ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0), http.StatusUnprocessableEntity) - return - } - - if direction == model.TransferDirectionWithdrawal { - acctBalance, err := h.accountService.GetAccountBalance(accountID) - if err != nil { - slog.Error("failed to get account balance", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - if amountCents > acctBalance { - ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0), http.StatusUnprocessableEntity) - return - } - } - - _, err = h.accountService.CreateTransfer(service.CreateTransferDTO{ - AccountID: accountID, - Amount: amountCents, - Direction: direction, - Note: note, - CreatedBy: user.ID, - }, availableBalance) - if err != nil { - slog.Error("failed to create transfer", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // Return updated account card + OOB balance summary - accountBalance, err := h.accountService.GetAccountBalance(accountID) - if err != nil { - slog.Error("failed to get account balance", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - account, _ := h.accountService.GetAccount(accountID) - acctWithBalance := model.MoneyAccountWithBalance{ - MoneyAccount: *account, - BalanceCents: accountBalance, - } - - // Recalculate available balance after transfer - totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID) - newAvailable := totalBalance - totalAllocated - - w.Header().Set("HX-Trigger", "transferSuccess") - ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) - ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, true)) - - transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) - ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) -} - -func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - accountID := r.PathValue("accountID") - - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - transferID := r.PathValue("transferID") - err := h.accountService.DeleteTransfer(transferID) - if err != nil { - slog.Error("failed to delete transfer", "error", err, "transfer_id", transferID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // Return updated account card + OOB balance summary - accountBalance, err := h.accountService.GetAccountBalance(accountID) - if err != nil { - slog.Error("failed to get account balance", "error", err, "account_id", accountID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - account, _ := h.accountService.GetAccount(accountID) - acctWithBalance := model.MoneyAccountWithBalance{ - MoneyAccount: *account, - BalanceCents: accountBalance, - } - - totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID) - totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID) - - ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true)) - ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true)) - - transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1) - ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true)) - - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Transfer deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -// --- Transfer History --- - -func (h *SpaceHandler) GetTransferHistory(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 - } - - transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, page) - if err != nil { - slog.Error("failed to get transfers", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, page, totalPages, false)) -} - -// --- Recurring Deposits --- - -func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit { - rd, err := h.recurringDepositService.GetRecurringDeposit(depositID) - if err != nil { - http.Error(w, "Recurring deposit not found", http.StatusNotFound) - return nil - } - if rd.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return rd -} - -func (h *SpaceHandler) CreateRecurringDeposit(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 - } - - accountID := r.FormValue("account_id") - amountStr := r.FormValue("amount") - frequencyStr := r.FormValue("frequency") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - title := r.FormValue("title") - - if accountID == "" || amountStr == "" || frequencyStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - // Verify account belongs to space - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) { - ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - rd, err := h.recurringDepositService.CreateRecurringDeposit(service.CreateRecurringDepositDTO{ - SpaceID: spaceID, - AccountID: accountID, - Amount: amountCents, - Frequency: model.Frequency(frequencyStr), - StartDate: startDate, - EndDate: endDate, - Title: title, - CreatedBy: user.ID, - }) - if err != nil { - slog.Error("failed to create recurring deposit", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - // Build response with account name - accounts, _ := h.accountService.GetAccountsForSpace(spaceID) - var accountName string - for _, acct := range accounts { - if acct.ID == rd.AccountID { - accountName = acct.Name - break - } - } - - rdWithAccount := &model.RecurringDepositWithAccount{ - RecurringDeposit: *rd, - AccountName: accountName, - } - - ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts)) -} - -func (h *SpaceHandler) UpdateRecurringDeposit(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringDepositID := r.PathValue("recurringDepositID") - - if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - accountID := r.FormValue("account_id") - amountStr := r.FormValue("amount") - frequencyStr := r.FormValue("frequency") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - title := r.FormValue("title") - - if accountID == "" || amountStr == "" || frequencyStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - // Verify account belongs to space - if h.getAccountForSpace(w, spaceID, accountID) == nil { - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) { - ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - updated, err := h.recurringDepositService.UpdateRecurringDeposit(service.UpdateRecurringDepositDTO{ - ID: recurringDepositID, - AccountID: accountID, - Amount: amountCents, - Frequency: model.Frequency(frequencyStr), - StartDate: startDate, - EndDate: endDate, - Title: title, - }) - if err != nil { - slog.Error("failed to update recurring deposit", "error", err, "id", recurringDepositID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - accounts, _ := h.accountService.GetAccountsForSpace(spaceID) - var accountName string - for _, acct := range accounts { - if acct.ID == updated.AccountID { - accountName = acct.Name - break - } - } - - rdWithAccount := &model.RecurringDepositWithAccount{ - RecurringDeposit: *updated, - AccountName: accountName, - } - - ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts)) -} - -func (h *SpaceHandler) DeleteRecurringDeposit(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringDepositID := r.PathValue("recurringDepositID") - - if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil { - return - } - - if err := h.recurringDepositService.DeleteRecurringDeposit(recurringDepositID); err != nil { - slog.Error("failed to delete recurring deposit", "error", err, "id", recurringDepositID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Recurring deposit deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) ToggleRecurringDeposit(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringDepositID := r.PathValue("recurringDepositID") - - if h.getRecurringDepositForSpace(w, spaceID, recurringDepositID) == nil { - return - } - - updated, err := h.recurringDepositService.ToggleRecurringDeposit(recurringDepositID) - if err != nil { - slog.Error("failed to toggle recurring deposit", "error", err, "id", recurringDepositID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - accounts, _ := h.accountService.GetAccountsForSpace(spaceID) - var accountName string - for _, acct := range accounts { - if acct.ID == updated.AccountID { - accountName = acct.Name - break - } - } - - rdWithAccount := &model.RecurringDepositWithAccount{ - RecurringDeposit: *updated, - AccountName: accountName, - } - - ui.Render(w, r, moneyaccount.RecurringDepositItem(spaceID, rdWithAccount, accounts)) -} - -// --- Payment Methods --- - -func (h *SpaceHandler) getMethodForSpace(w http.ResponseWriter, spaceID, methodID string) *model.PaymentMethod { - method, err := h.methodService.GetMethod(methodID) - if err != nil { - http.Error(w, "Payment method not found", http.StatusNotFound) - return nil - } - if method.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return method -} - -func (h *SpaceHandler) PaymentMethodsPage(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 - } - - methods, err := h.methodService.GetMethodsForSpace(spaceID) - if err != nil { - slog.Error("failed to get payment methods for space", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.SpacePaymentMethodsPage(space, methods)) -} - -func (h *SpaceHandler) CreatePaymentMethod(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 - } - - name := r.FormValue("name") - methodType := model.PaymentMethodType(r.FormValue("type")) - lastFour := r.FormValue("last_four") - - method, err := h.methodService.CreateMethod(service.CreatePaymentMethodDTO{ - SpaceID: spaceID, - Name: name, - Type: methodType, - LastFour: lastFour, - CreatedBy: user.ID, - }) - if err != nil { - slog.Error("failed to create payment method", "error", err, "space_id", spaceID) - ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity) - return - } - - ui.Render(w, r, paymentmethod.MethodItem(spaceID, method)) -} - -func (h *SpaceHandler) UpdatePaymentMethod(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - methodID := r.PathValue("methodID") - - if h.getMethodForSpace(w, spaceID, methodID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - name := r.FormValue("name") - methodType := model.PaymentMethodType(r.FormValue("type")) - lastFour := r.FormValue("last_four") - - updatedMethod, err := h.methodService.UpdateMethod(service.UpdatePaymentMethodDTO{ - ID: methodID, - Name: name, - Type: methodType, - LastFour: lastFour, - }) - if err != nil { - slog.Error("failed to update payment method", "error", err, "method_id", methodID) - ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity) - return - } - - ui.Render(w, r, paymentmethod.MethodItem(spaceID, updatedMethod)) -} - -func (h *SpaceHandler) DeletePaymentMethod(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - methodID := r.PathValue("methodID") - - if h.getMethodForSpace(w, spaceID, methodID) == nil { - return - } - - err := h.methodService.DeleteMethod(methodID) - if err != nil { - slog.Error("failed to delete payment method", "error", err, "method_id", methodID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Payment method deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -// --- Recurring Expenses --- - -func (h *SpaceHandler) getRecurringForSpace(w http.ResponseWriter, spaceID, recurringID string) *model.RecurringExpense { - re, err := h.recurringService.GetRecurringExpense(recurringID) - if err != nil { - http.Error(w, "Recurring expense not found", http.StatusNotFound) - return nil - } - if re.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return re -} - -func (h *SpaceHandler) RecurringExpensesPage(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 - } - - // Lazy check: process any due recurrences for this space - h.recurringService.ProcessDueRecurrencesForSpace(spaceID, time.Now()) - - recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) - if err != nil { - slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - tags, err := h.tagService.GetTagsForSpace(spaceID) - if err != nil { - slog.Error("failed to get tags", "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.SpaceRecurringPage(space, recs, tags, methods)) -} - -func (h *SpaceHandler) CreateRecurringExpense(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 - } - - description := r.FormValue("description") - amountStr := r.FormValue("amount") - typeStr := r.FormValue("type") - frequencyStr := r.FormValue("frequency") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - tagNames := r.Form["tags"] - - if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil { - ui.RenderError(w, r, "Invalid amount format.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date format.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date format.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - expenseType := model.ExpenseType(typeStr) - if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { - ui.RenderError(w, r, "Invalid transaction type.", http.StatusUnprocessableEntity) - return - } - - frequency := model.Frequency(frequencyStr) - - // Tag processing - existingTags, err := h.tagService.GetTagsForSpace(spaceID) - if err != nil { - slog.Error("failed to get tags", "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 tag", "error", err, "tag_name", tagName) - continue - } - finalTagIDs = append(finalTagIDs, newTag.ID) - existingTagsMap[tagName] = newTag.ID - } - processedTags[tagName] = true - } - - var paymentMethodID *string - if pmid := r.FormValue("payment_method_id"); pmid != "" { - paymentMethodID = &pmid - } - - re, err := h.recurringService.CreateRecurringExpense(service.CreateRecurringExpenseDTO{ - SpaceID: spaceID, - UserID: user.ID, - Description: description, - Amount: amountCents, - Type: expenseType, - PaymentMethodID: paymentMethodID, - Frequency: frequency, - StartDate: startDate, - EndDate: endDate, - TagIDs: finalTagIDs, - }) - if err != nil { - slog.Error("failed to create recurring expense", "error", err) - http.Error(w, "Failed to create recurring expense.", http.StatusInternalServerError) - return - } - - // Fetch tags/method for the response - spaceTags, _ := h.tagService.GetTagsForSpace(spaceID) - tagsMap, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) - for _, item := range tagsMap { - if item.ID == re.ID { - ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil, spaceTags)) - return - } - } - - // Fallback: render without tags - ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil, spaceTags)) -} - -func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringID := r.PathValue("recurringID") - - if h.getRecurringForSpace(w, spaceID, recurringID) == 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") - frequencyStr := r.FormValue("frequency") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - tagNames := r.Form["tags"] - - if description == "" || amountStr == "" || typeStr == "" || frequencyStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil { - ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - // Tag processing - existingTags, err := h.tagService.GetTagsForSpace(spaceID) - if err != nil { - 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 { - continue - } - finalTagIDs = append(finalTagIDs, newTag.ID) - } - processedTags[tagName] = true - } - - var paymentMethodID *string - if pmid := r.FormValue("payment_method_id"); pmid != "" { - paymentMethodID = &pmid - } - - updated, err := h.recurringService.UpdateRecurringExpense(service.UpdateRecurringExpenseDTO{ - ID: recurringID, - Description: description, - Amount: amountCents, - Type: model.ExpenseType(typeStr), - PaymentMethodID: paymentMethodID, - Frequency: model.Frequency(frequencyStr), - StartDate: startDate, - EndDate: endDate, - TagIDs: finalTagIDs, - }) - if err != nil { - slog.Error("failed to update recurring expense", "error", err) - http.Error(w, "Failed to update.", http.StatusInternalServerError) - return - } - - // Build response with tags/method - updateSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID) - tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) - for _, item := range tagsMapResult { - if item.ID == updated.ID { - methods, _ := h.methodService.GetMethodsForSpace(spaceID) - ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, updateSpaceTags)) - return - } - } - - ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, updateSpaceTags)) -} - -func (h *SpaceHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringID := r.PathValue("recurringID") - - if h.getRecurringForSpace(w, spaceID, recurringID) == nil { - return - } - - if err := h.recurringService.DeleteRecurringExpense(recurringID); err != nil { - slog.Error("failed to delete recurring expense", "error", err, "recurring_id", recurringID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Recurring expense deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - recurringID := r.PathValue("recurringID") - - if h.getRecurringForSpace(w, spaceID, recurringID) == nil { - return - } - - updated, err := h.recurringService.ToggleRecurringExpense(recurringID) - if err != nil { - slog.Error("failed to toggle recurring expense", "error", err, "recurring_id", recurringID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - toggleSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID) - tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID) - for _, item := range tagsMapResult { - if item.ID == updated.ID { - methods, _ := h.methodService.GetMethodsForSpace(spaceID) - ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, toggleSpaceTags)) - return - } - } - - ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, toggleSpaceTags)) -} - -// --- Budgets --- - -// processTagNames normalizes tag names, deduplicates them, and resolves them -// to tag IDs. Tags that don't exist are auto-created. -func (h *SpaceHandler) processTagNames(spaceID string, tagNames []string) ([]string, error) { - existingTags, err := h.tagService.GetTagsForSpace(spaceID) - if err != nil { - return nil, err - } - - 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 { - newTag, err := h.tagService.CreateTag(spaceID, tagName, nil) - if err != nil { - slog.Error("failed to create new tag", "error", err, "tag_name", tagName) - continue - } - finalTagIDs = append(finalTagIDs, newTag.ID) - existingTagsMap[tagName] = newTag.ID - } - processedTags[tagName] = true - } - - return finalTagIDs, nil -} - -func (h *SpaceHandler) getBudgetForSpace(w http.ResponseWriter, spaceID, budgetID string) *model.Budget { - budget, err := h.budgetService.GetBudget(budgetID) - if err != nil { - http.Error(w, "Budget not found", http.StatusNotFound) - return nil - } - if budget.SpaceID != spaceID { - http.Error(w, "Not Found", http.StatusNotFound) - return nil - } - return budget -} - -func (h *SpaceHandler) BudgetsPage(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 - } - - tags, err := h.tagService.GetTagsForSpace(spaceID) - if err != nil { - slog.Error("failed to get tags", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID) - if err != nil { - slog.Error("failed to get budgets", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.SpaceBudgetsPage(space, budgets, tags)) -} - -func (h *SpaceHandler) CreateBudget(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 - } - - tagNames := r.Form["tags"] - amountStr := r.FormValue("amount") - periodStr := r.FormValue("period") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - - if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - tagIDs, err := h.processTagNames(spaceID, tagNames) - if err != nil { - slog.Error("failed to process tag names", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if len(tagIDs) == 0 { - ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity) - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil { - ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - _, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{ - SpaceID: spaceID, - TagIDs: tagIDs, - Amount: amountCents, - Period: model.BudgetPeriod(periodStr), - StartDate: startDate, - EndDate: endDate, - CreatedBy: user.ID, - }) - if err != nil { - slog.Error("failed to create budget", "error", err) - http.Error(w, "Failed to create budget.", http.StatusInternalServerError) - return - } - - // Refresh the full budgets list - tags, _ := h.tagService.GetTagsForSpace(spaceID) - budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID) - ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) -} - -func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - budgetID := r.PathValue("budgetID") - - if h.getBudgetForSpace(w, spaceID, budgetID) == nil { - return - } - - if err := r.ParseForm(); err != nil { - ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) - return - } - - tagNames := r.Form["tags"] - amountStr := r.FormValue("amount") - periodStr := r.FormValue("period") - startDateStr := r.FormValue("start_date") - endDateStr := r.FormValue("end_date") - - if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" { - ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity) - return - } - - tagIDs, err := h.processTagNames(spaceID, tagNames) - if err != nil { - slog.Error("failed to process tag names", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - if len(tagIDs) == 0 { - ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity) - return - } - - amountDecimal, err := decimal.NewFromString(amountStr) - if err != nil { - ui.RenderError(w, r, "Invalid amount.", http.StatusUnprocessableEntity) - return - } - amountCents := int(amountDecimal.Mul(decimal.NewFromInt(100)).IntPart()) - - startDate, err := time.Parse("2006-01-02", startDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid start date.", http.StatusUnprocessableEntity) - return - } - - var endDate *time.Time - if endDateStr != "" { - ed, err := time.Parse("2006-01-02", endDateStr) - if err != nil { - ui.RenderError(w, r, "Invalid end date.", http.StatusUnprocessableEntity) - return - } - endDate = &ed - } - - _, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{ - ID: budgetID, - TagIDs: tagIDs, - Amount: amountCents, - Period: model.BudgetPeriod(periodStr), - StartDate: startDate, - EndDate: endDate, - }) - if err != nil { - slog.Error("failed to update budget", "error", err) - http.Error(w, "Failed to update budget.", http.StatusInternalServerError) - return - } - - // Refresh the full budgets list - tags, _ := h.tagService.GetTagsForSpace(spaceID) - budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID) - ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) -} - -func (h *SpaceHandler) DeleteBudget(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - budgetID := r.PathValue("budgetID") - - if h.getBudgetForSpace(w, spaceID, budgetID) == nil { - return - } - - if err := h.budgetService.DeleteBudget(budgetID); err != nil { - slog.Error("failed to delete budget", "error", err, "budget_id", budgetID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - ui.RenderToast(w, r, toast.Toast(toast.Props{ - Title: "Budget deleted", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) -} - -func (h *SpaceHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - - tags, _ := h.tagService.GetTagsForSpace(spaceID) - budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID) - if err != nil { - slog.Error("failed to get budgets", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags)) -} - -// --- Reports --- - -func (h *SpaceHandler) GetReportCharts(w http.ResponseWriter, r *http.Request) { - spaceID := r.PathValue("spaceID") - - rangeKey := r.URL.Query().Get("range") - now := time.Now() - presets := service.GetPresetDateRanges(now) - - var from, to time.Time - activeRange := "this_month" - - if rangeKey == "custom" { - fromStr := r.URL.Query().Get("from") - toStr := r.URL.Query().Get("to") - var err error - from, err = time.Parse("2006-01-02", fromStr) - if err != nil { - from = presets[0].From - } - to, err = time.Parse("2006-01-02", toStr) - if err != nil { - to = presets[0].To - } - activeRange = "custom" - } else { - for _, p := range presets { - if p.Key == rangeKey { - from = p.From - to = p.To - activeRange = p.Key - break - } - } - if from.IsZero() { - from = presets[0].From - to = presets[0].To - } - } - - report, err := h.reportService.GetSpendingReport(spaceID, from, to) - if err != nil { - slog.Error("failed to get report charts", "error", err, "space_id", spaceID) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - ui.Render(w, r, pages.ReportCharts(spaceID, report, from, to, presets, activeRange)) -} - func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) { lists, err := h.listService.GetListsForSpace(spaceID) if err != nil { diff --git a/internal/handler/space_settings_handler.go b/internal/handler/space_settings_handler.go new file mode 100644 index 0000000..797810b --- /dev/null +++ b/internal/handler/space_settings_handler.go @@ -0,0 +1,300 @@ +package handler + +import ( + "log/slog" + "net/http" + "time" + + "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/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type SpaceSettingsHandler struct { + spaceService *service.SpaceService + inviteService *service.InviteService +} + +func NewSpaceSettingsHandler(ss *service.SpaceService, is *service.InviteService) *SpaceSettingsHandler { + return &SpaceSettingsHandler{ + spaceService: ss, + inviteService: is, + } +} + +func (h *SpaceSettingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to get space", "error", err, "space_id", spaceID) + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + members, err := h.spaceService.GetMembers(spaceID) + if err != nil { + slog.Error("failed to get members", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + isOwner := space.OwnerID == user.ID + + var pendingInvites []*model.SpaceInvitation + if isOwner { + pendingInvites, err = h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID)) +} + +func (h *SpaceSettingsHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + if name == "" { + ui.RenderError(w, r, "Name is required", http.StatusUnprocessableEntity) + return + } + + if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil { + slog.Error("failed to update space name", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceSettingsHandler) UpdateSpaceTimezone(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + tz := r.FormValue("timezone") + if tz == "" { + ui.RenderError(w, r, "Timezone is required", http.StatusUnprocessableEntity) + return + } + + if err := h.spaceService.UpdateSpaceTimezone(spaceID, tz); err != nil { + if err == service.ErrInvalidTimezone { + ui.RenderError(w, r, "Invalid timezone", http.StatusUnprocessableEntity) + return + } + slog.Error("failed to update space timezone", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceSettingsHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + userID := r.PathValue("userID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if userID == user.ID { + ui.RenderError(w, r, "Cannot remove yourself", http.StatusUnprocessableEntity) + return + } + + if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { + slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Member removed", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *SpaceSettingsHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := h.inviteService.CancelInvite(token); err != nil { + slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Invitation cancelled", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *SpaceSettingsHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + pendingInvites, err := h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites)) +} + +func (h *SpaceSettingsHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + email := r.FormValue("email") + if email == "" { + ui.RenderError(w, r, "Email is required", http.StatusUnprocessableEntity) + return + } + + _, err = h.inviteService.CreateInvite(spaceID, user.ID, email) + if err != nil { + slog.Error("failed to create invite", "error", err, "space_id", spaceID) + http.Error(w, "Failed to create invite", http.StatusInternalServerError) + return + } + + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Invitation sent", + Description: "An email has been sent to " + email, + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *SpaceSettingsHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + if user != nil { + spaceID, err := h.inviteService.AcceptInvite(token, user.ID) + if err != nil { + slog.Error("failed to accept invite", "error", err, "token", token) + ui.RenderError(w, r, "Failed to join space: "+err.Error(), http.StatusUnprocessableEntity) + return + } + + http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther) + return + } + + // Not logged in: set cookie and redirect to auth + http.SetCookie(w, &http.Cookie{ + Name: "pending_invite", + Value: token, + Path: "/", + Expires: time.Now().Add(1 * time.Hour), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect) +} diff --git a/internal/handler/space_test.go b/internal/handler/space_test.go index 3efd09c..1c6c6d5 100644 --- a/internal/handler/space_test.go +++ b/internal/handler/space_test.go @@ -12,7 +12,24 @@ import ( "github.com/stretchr/testify/assert" ) -func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler { +// testServices holds all services needed by tests, constructed once per DB. +type testServices struct { + spaceSvc *service.SpaceService + tagSvc *service.TagService + listSvc *service.ShoppingListService + expenseSvc *service.ExpenseService + inviteSvc *service.InviteService + accountSvc *service.MoneyAccountService + methodSvc *service.PaymentMethodService + recurringSvc *service.RecurringExpenseService + budgetSvc *service.BudgetService + reportSvc *service.ReportService + loanSvc *service.LoanService + receiptSvc *service.ReceiptService + recurringReceiptSvc *service.RecurringReceiptService +} + +func newTestServices(t *testing.T, dbi testutil.DBInfo) *testServices { t.Helper() spaceRepo := repository.NewSpaceRepository(dbi.DB) tagRepo := repository.NewTagRepository(dbi.DB) @@ -24,38 +41,39 @@ func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler { accountRepo := repository.NewMoneyAccountRepository(dbi.DB) methodRepo := repository.NewPaymentMethodRepository(dbi.DB) recurringRepo := repository.NewRecurringExpenseRepository(dbi.DB) - recurringDepositRepo := repository.NewRecurringDepositRepository(dbi.DB) budgetRepo := repository.NewBudgetRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB) loanRepo := repository.NewLoanRepository(dbi.DB) receiptRepo := repository.NewReceiptRepository(dbi.DB) recurringReceiptRepo := repository.NewRecurringReceiptRepository(dbi.DB) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) + spaceSvc := service.NewSpaceService(spaceRepo) expenseSvc := service.NewExpenseService(expenseRepo) loanSvc := service.NewLoanService(loanRepo, receiptRepo) receiptSvc := service.NewReceiptService(receiptRepo, loanRepo, accountRepo) recurringReceiptSvc := service.NewRecurringReceiptService(recurringReceiptRepo, receiptSvc, loanRepo, profileRepo, spaceRepo) - return NewSpaceHandler( - service.NewSpaceService(spaceRepo), - service.NewTagService(tagRepo), - service.NewShoppingListService(listRepo, itemRepo), - expenseSvc, - service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc), - service.NewMoneyAccountService(accountRepo), - service.NewPaymentMethodService(methodRepo), - service.NewRecurringExpenseService(recurringRepo, expenseRepo, profileRepo, spaceRepo), - service.NewRecurringDepositService(recurringDepositRepo, accountRepo, expenseSvc, profileRepo, spaceRepo), - service.NewBudgetService(budgetRepo), - service.NewReportService(expenseRepo), - loanSvc, - receiptSvc, - recurringReceiptSvc, - ) + + return &testServices{ + spaceSvc: spaceSvc, + tagSvc: service.NewTagService(tagRepo), + listSvc: service.NewShoppingListService(listRepo, itemRepo), + expenseSvc: expenseSvc, + inviteSvc: service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc), + accountSvc: service.NewMoneyAccountService(accountRepo), + methodSvc: service.NewPaymentMethodService(methodRepo), + recurringSvc: service.NewRecurringExpenseService(recurringRepo, expenseRepo, profileRepo, spaceRepo), + budgetSvc: service.NewBudgetService(budgetRepo), + reportSvc: service.NewReportService(expenseRepo), + loanSvc: loanSvc, + receiptSvc: receiptSvc, + recurringReceiptSvc: recurringReceiptSvc, + } } -func TestSpaceHandler_CreateList(t *testing.T) { +func TestListHandler_CreateList(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewListHandler(svcs.spaceSvc, svcs.listSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") @@ -69,9 +87,10 @@ func TestSpaceHandler_CreateList(t *testing.T) { }) } -func TestSpaceHandler_CreateList_EmptyName(t *testing.T) { +func TestListHandler_CreateList_EmptyName(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewListHandler(svcs.spaceSvc, svcs.listSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") @@ -85,9 +104,10 @@ func TestSpaceHandler_CreateList_EmptyName(t *testing.T) { }) } -func TestSpaceHandler_DeleteList(t *testing.T) { +func TestListHandler_DeleteList(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewListHandler(svcs.spaceSvc, svcs.listSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Groceries") @@ -103,9 +123,10 @@ func TestSpaceHandler_DeleteList(t *testing.T) { }) } -func TestSpaceHandler_AddItemToList(t *testing.T) { +func TestListHandler_AddItemToList(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewListHandler(svcs.spaceSvc, svcs.listSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Groceries") @@ -121,9 +142,10 @@ func TestSpaceHandler_AddItemToList(t *testing.T) { }) } -func TestSpaceHandler_CreateTag(t *testing.T) { +func TestTagHandler_CreateTag(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewTagHandler(svcs.spaceSvc, svcs.tagSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") @@ -137,9 +159,10 @@ func TestSpaceHandler_CreateTag(t *testing.T) { }) } -func TestSpaceHandler_DeleteTag(t *testing.T) { +func TestTagHandler_DeleteTag(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewTagHandler(svcs.spaceSvc, svcs.tagSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "food", nil) @@ -155,9 +178,10 @@ func TestSpaceHandler_DeleteTag(t *testing.T) { }) } -func TestSpaceHandler_CreateAccount(t *testing.T) { +func TestAccountHandler_CreateAccount(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewAccountHandler(svcs.spaceSvc, svcs.accountSvc, svcs.expenseSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") @@ -171,9 +195,10 @@ func TestSpaceHandler_CreateAccount(t *testing.T) { }) } -func TestSpaceHandler_CreatePaymentMethod(t *testing.T) { +func TestMethodHandler_CreatePaymentMethod(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { - h := newTestSpaceHandler(t, dbi) + svcs := newTestServices(t, dbi) + h := NewMethodHandler(svcs.spaceSvc, svcs.methodSvc) user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test") space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space") diff --git a/internal/handler/tag_handler.go b/internal/handler/tag_handler.go new file mode 100644 index 0000000..fa8d3a1 --- /dev/null +++ b/internal/handler/tag_handler.go @@ -0,0 +1,107 @@ +package handler + +import ( + "log/slog" + "net/http" + + "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/tag" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +type TagHandler struct { + spaceService *service.SpaceService + tagService *service.TagService +} + +func NewTagHandler(ss *service.SpaceService, ts *service.TagService) *TagHandler { + return &TagHandler{ + spaceService: ss, + tagService: ts, + } +} + +// getTagForSpace fetches a tag and verifies it belongs to the given space. +func (h *TagHandler) getTagForSpace(w http.ResponseWriter, spaceID, tagID string) *model.Tag { + t, err := h.tagService.GetTagByID(tagID) + if err != nil { + http.Error(w, "Tag not found", http.StatusNotFound) + return nil + } + if t.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return t +} + +func (h *TagHandler) TagsPage(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 + } + + 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 + } + + ui.Render(w, r, pages.SpaceTagsPage(space, tags)) +} + +func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + if err := r.ParseForm(); err != nil { + ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity) + return + } + + name := r.FormValue("name") + color := r.FormValue("color") // color is optional + + var colorPtr *string + if color != "" { + colorPtr = &color + } + + newTag, err := h.tagService.CreateTag(spaceID, name, colorPtr) + if err != nil { + slog.Error("failed to create tag", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, tag.Tag(newTag)) +} + +func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + tagID := r.PathValue("tagID") + + if h.getTagForSpace(w, spaceID, tagID) == nil { + return + } + + err := h.tagService.DeleteTag(tagID) + if err != nil { + slog.Error("failed to delete tag", "error", err, "tag_id", tagID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + ui.RenderToast(w, r, toast.Toast(toast.Props{ + Title: "Tag deleted", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index f95c217..2f6619a 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -69,12 +69,7 @@ func RequireGuest(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := ctxkeys.User(r.Context()) if user != nil { - if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", "/app/dashboard") - w.WriteHeader(http.StatusSeeOther) - return - } - http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther) + redirect(w, r, "/app/dashboard", http.StatusSeeOther) return } next.ServeHTTP(w, r) @@ -86,14 +81,7 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := ctxkeys.User(r.Context()) if user == nil { - // For HTMX requests, use HX-Redirect header to force full page redirect - if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", "/auth") - w.WriteHeader(http.StatusSeeOther) - return - } - // For regular requests, use standard redirect - http.Redirect(w, r, "/auth", http.StatusSeeOther) + redirect(w, r, "/auth", http.StatusSeeOther) return } @@ -101,13 +89,7 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc { // Uses profile.Name as indicator (empty = incomplete onboarding) profile := ctxkeys.Profile(r.Context()) if profile.Name == "" && r.URL.Path != "/auth/onboarding" { - // User hasn't completed onboarding, redirect to onboarding - if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", "/auth/onboarding") - w.WriteHeader(http.StatusSeeOther) - return - } - http.Redirect(w, r, "/auth/onboarding", http.StatusSeeOther) + redirect(w, r, "/auth/onboarding", http.StatusSeeOther) return } diff --git a/internal/middleware/redirect.go b/internal/middleware/redirect.go index 89ab36f..99f4bf1 100644 --- a/internal/middleware/redirect.go +++ b/internal/middleware/redirect.go @@ -3,12 +3,7 @@ package middleware import "net/http" func Redirect(path string) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", path) - w.WriteHeader(http.StatusSeeOther) - return - } - http.Redirect(w, r, path, http.StatusSeeOther) - }) + return func(w http.ResponseWriter, r *http.Request) { + redirect(w, r, path, http.StatusSeeOther) + } } diff --git a/internal/middleware/utils.go b/internal/middleware/utils.go index 4a5fa6a..bd6bfb2 100644 --- a/internal/middleware/utils.go +++ b/internal/middleware/utils.go @@ -7,15 +7,16 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/pages" ) +// Redirect handles both HTMX and regular HTTP redirects. +// For HTMX requests, it sets the HX-Redirect header; for regular requests, +// it uses http.Redirect. func redirect(w http.ResponseWriter, r *http.Request, path string, code int) { - // For HTMX requests, use HX-Redirect header to force full page redirect if r.Header.Get("HX-Request") == "true" { - w.Header().Set("HX-Redirect", "/auth") + w.Header().Set("HX-Redirect", path) w.WriteHeader(code) return } - // For regular requests, use standard redirect - http.Redirect(w, r, "/auth", code) + http.Redirect(w, r, path, code) } func notfound(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/recurring_deposit.go b/internal/model/recurring_deposit.go deleted file mode 100644 index 2e748de..0000000 --- a/internal/model/recurring_deposit.go +++ /dev/null @@ -1,24 +0,0 @@ -package model - -import "time" - -type RecurringDeposit struct { - ID string `db:"id"` - SpaceID string `db:"space_id"` - AccountID string `db:"account_id"` - AmountCents int `db:"amount_cents"` - Frequency Frequency `db:"frequency"` - StartDate time.Time `db:"start_date"` - EndDate *time.Time `db:"end_date"` - NextOccurrence time.Time `db:"next_occurrence"` - IsActive bool `db:"is_active"` - Title string `db:"title"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -type RecurringDepositWithAccount struct { - RecurringDeposit - AccountName string -} diff --git a/internal/repository/budget.go b/internal/repository/budget.go index 629e47a..7a5e998 100644 --- a/internal/repository/budget.go +++ b/internal/repository/budget.go @@ -32,29 +32,24 @@ func NewBudgetRepository(db *sqlx.DB) BudgetRepository { } func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + query := `INSERT INTO budgets (id, space_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);` + if _, err := tx.Exec(query, budget.ID, budget.SpaceID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt); err != nil { + return err + } - query := `INSERT INTO budgets (id, space_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);` - _, err = tx.Exec(query, budget.ID, budget.SpaceID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt) - if err != nil { - return err - } - - if len(tagIDs) > 0 { - tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);` - for _, tagID := range tagIDs { - if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil { - return err + if len(tagIDs) > 0 { + tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil { + return err + } } } - } - return tx.Commit() + return nil + }) } func (r *budgetRepository) GetByID(id string) (*model.Budget, error) { @@ -136,36 +131,37 @@ func (r *budgetRepository) GetTagsByBudgetIDs(budgetIDs []string) (map[string][] } func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + query := `UPDATE budgets SET amount_cents = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;` + if _, err := tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil { + return err + } - query := `UPDATE budgets SET amount_cents = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;` - _, err = tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID) - if err != nil { - return err - } + if _, err := tx.Exec(`DELETE FROM budget_tags WHERE budget_id = $1;`, budget.ID); err != nil { + return err + } - // Replace tags: delete old, insert new - if _, err := tx.Exec(`DELETE FROM budget_tags WHERE budget_id = $1;`, budget.ID); err != nil { - return err - } - - if len(tagIDs) > 0 { - tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);` - for _, tagID := range tagIDs { - if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil { - return err + if len(tagIDs) > 0 { + tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil { + return err + } } } - } - return tx.Commit() + return nil + }) } func (r *budgetRepository) Delete(id string) error { - _, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id) + result, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err == nil && rows == 0 { + return ErrBudgetNotFound + } return err } diff --git a/internal/repository/expense.go b/internal/repository/expense.go index c2d3208..3b029ce 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -40,43 +40,34 @@ func NewExpenseRepository(db *sqlx.DB) ExpenseRepository { } func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);` + _, err := tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt) + if err != nil { + return err + } - // Insert Expense - queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, recurring_expense_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);` - _, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt) - if err != nil { - return err - } - - // Insert Tags - if len(tagIDs) > 0 { - queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` - for _, tagID := range tagIDs { - _, err := tx.Exec(queryTags, expense.ID, tagID) - if err != nil { - return err + if len(tagIDs) > 0 { + queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(queryTags, expense.ID, tagID); err != nil { + return err + } } } - } - // Insert Items - if len(itemIDs) > 0 { - queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);` - for _, itemID := range itemIDs { - _, err := tx.Exec(queryItems, expense.ID, itemID) - if err != nil { - return err + if len(itemIDs) > 0 { + queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);` + for _, itemID := range itemIDs { + if _, err := tx.Exec(queryItems, expense.ID, itemID); err != nil { + return err + } } } - } - return tx.Commit() + return nil + }) } func (r *expenseRepository) GetByID(id string) (*model.Expense, error) { @@ -223,38 +214,38 @@ func (r *expenseRepository) GetPaymentMethodsByExpenseIDs(expenseIDs []string) ( } func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;` + if _, err := tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil { + return err + } - query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;` - _, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID) - if err != nil { - return err - } + if _, err := tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID); 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 + 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() + return nil + }) } func (r *expenseRepository) Delete(id string) error { - _, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id) + result, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err == nil && rows == 0 { + return ErrExpenseNotFound + } return err } diff --git a/internal/repository/helpers.go b/internal/repository/helpers.go new file mode 100644 index 0000000..1ef2e49 --- /dev/null +++ b/internal/repository/helpers.go @@ -0,0 +1,18 @@ +package repository + +import "github.com/jmoiron/sqlx" + +// WithTx runs fn inside a transaction. If fn returns an error, the transaction +// is rolled back; otherwise it is committed. +func WithTx(db *sqlx.DB, fn func(*sqlx.Tx) error) error { + tx, err := db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/repository/recurring_deposit.go b/internal/repository/recurring_deposit.go deleted file mode 100644 index ef05228..0000000 --- a/internal/repository/recurring_deposit.go +++ /dev/null @@ -1,105 +0,0 @@ -package repository - -import ( - "database/sql" - "errors" - "time" - - "git.juancwu.dev/juancwu/budgit/internal/model" - "github.com/jmoiron/sqlx" -) - -var ( - ErrRecurringDepositNotFound = errors.New("recurring deposit not found") -) - -type RecurringDepositRepository interface { - Create(rd *model.RecurringDeposit) error - GetByID(id string) (*model.RecurringDeposit, error) - GetBySpaceID(spaceID string) ([]*model.RecurringDeposit, error) - Update(rd *model.RecurringDeposit) error - Delete(id string) error - SetActive(id string, active bool) error - GetDueRecurrences(now time.Time) ([]*model.RecurringDeposit, error) - GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringDeposit, error) - UpdateNextOccurrence(id string, next time.Time) error - Deactivate(id string) error -} - -type recurringDepositRepository struct { - db *sqlx.DB -} - -func NewRecurringDepositRepository(db *sqlx.DB) RecurringDepositRepository { - return &recurringDepositRepository{db: db} -} - -func (r *recurringDepositRepository) Create(rd *model.RecurringDeposit) error { - query := `INSERT INTO recurring_deposits (id, space_id, account_id, amount_cents, frequency, start_date, end_date, next_occurrence, is_active, title, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);` - _, err := r.db.Exec(query, rd.ID, rd.SpaceID, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.IsActive, rd.Title, rd.CreatedBy, rd.CreatedAt, rd.UpdatedAt) - return err -} - -func (r *recurringDepositRepository) GetByID(id string) (*model.RecurringDeposit, error) { - rd := &model.RecurringDeposit{} - query := `SELECT * FROM recurring_deposits WHERE id = $1;` - err := r.db.Get(rd, query, id) - if err == sql.ErrNoRows { - return nil, ErrRecurringDepositNotFound - } - return rd, err -} - -func (r *recurringDepositRepository) GetBySpaceID(spaceID string) ([]*model.RecurringDeposit, error) { - var results []*model.RecurringDeposit - query := `SELECT * FROM recurring_deposits WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;` - err := r.db.Select(&results, query, spaceID) - return results, err -} - -func (r *recurringDepositRepository) Update(rd *model.RecurringDeposit) error { - query := `UPDATE recurring_deposits SET account_id = $1, amount_cents = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, title = $7, updated_at = $8 WHERE id = $9;` - result, err := r.db.Exec(query, rd.AccountID, rd.AmountCents, rd.Frequency, rd.StartDate, rd.EndDate, rd.NextOccurrence, rd.Title, rd.UpdatedAt, rd.ID) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err == nil && rows == 0 { - return ErrRecurringDepositNotFound - } - return err -} - -func (r *recurringDepositRepository) Delete(id string) error { - _, err := r.db.Exec(`DELETE FROM recurring_deposits WHERE id = $1;`, id) - return err -} - -func (r *recurringDepositRepository) SetActive(id string, active bool) error { - _, err := r.db.Exec(`UPDATE recurring_deposits SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id) - return err -} - -func (r *recurringDepositRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringDeposit, error) { - var results []*model.RecurringDeposit - query := `SELECT * FROM recurring_deposits WHERE is_active = true AND next_occurrence <= $1;` - err := r.db.Select(&results, query, now) - return results, err -} - -func (r *recurringDepositRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringDeposit, error) { - var results []*model.RecurringDeposit - query := `SELECT * FROM recurring_deposits WHERE is_active = true AND space_id = $1 AND next_occurrence <= $2;` - err := r.db.Select(&results, query, spaceID, now) - return results, err -} - -func (r *recurringDepositRepository) UpdateNextOccurrence(id string, next time.Time) error { - _, err := r.db.Exec(`UPDATE recurring_deposits SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id) - return err -} - -func (r *recurringDepositRepository) Deactivate(id string) error { - return r.SetActive(id, false) -} diff --git a/internal/repository/recurring_expense.go b/internal/repository/recurring_expense.go index 6df86a4..f55ad9e 100644 --- a/internal/repository/recurring_expense.go +++ b/internal/repository/recurring_expense.go @@ -37,29 +37,24 @@ func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository { } func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);` + if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil { + return err + } - query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount_cents, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14);` - _, err = tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt) - if err != nil { - return err - } - - if len(tagIDs) > 0 { - tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);` - for _, tagID := range tagIDs { - if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil { - return err + if len(tagIDs) > 0 { + tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil { + return err + } } } - } - return tx.Commit() + return nil + }) } func (r *recurringExpenseRepository) GetByID(id string) (*model.RecurringExpense, error) { @@ -165,37 +160,38 @@ func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids } func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error { - tx, err := r.db.Beginx() - if err != nil { - return err - } - defer tx.Rollback() + return WithTx(r.db, func(tx *sqlx.Tx) error { + query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;` + if _, err := tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil { + return err + } - query := `UPDATE recurring_expenses SET description = $1, amount_cents = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;` - _, err = tx.Exec(query, re.Description, re.AmountCents, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID) - if err != nil { - return err - } + if _, err := tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID); err != nil { + return err + } - _, err = tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID) - if err != nil { - return err - } - - if len(tagIDs) > 0 { - tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);` - for _, tagID := range tagIDs { - if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil { - return err + if len(tagIDs) > 0 { + tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil { + return err + } } } - } - return tx.Commit() + return nil + }) } func (r *recurringExpenseRepository) Delete(id string) error { - _, err := r.db.Exec(`DELETE FROM recurring_expenses WHERE id = $1;`, id) + result, err := r.db.Exec(`DELETE FROM recurring_expenses WHERE id = $1;`, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err == nil && rows == 0 { + return ErrRecurringExpenseNotFound + } return err } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 020c6d1..0a23b2c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -10,11 +10,29 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/middleware" ) +// spaceRoute registers a space-protected route (no rate limit). +func spaceRoute(mux *http.ServeMux, spaceAccess func(http.HandlerFunc) http.HandlerFunc, pattern string, h http.HandlerFunc) { + mux.HandleFunc(pattern, middleware.RequireAuth(spaceAccess(h))) +} + +// spaceRouteLimited registers a rate-limited space-protected route. +func spaceRouteLimited(mux *http.ServeMux, spaceAccess func(http.HandlerFunc) http.HandlerFunc, limiter func(http.Handler) http.Handler, pattern string, h http.HandlerFunc) { + mux.Handle(pattern, limiter(middleware.RequireAuth(spaceAccess(h)))) +} + func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) home := handler.NewHomeHandler() settings := handler.NewSettingsHandler(a.AuthService, a.UserService, a.ProfileService) - space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.RecurringDepositService, a.BudgetService, a.ReportService, a.LoanService, a.ReceiptService, a.RecurringReceiptService) + space := handler.NewSpaceHandler(a.SpaceService, a.ExpenseService, a.MoneyAccountService, a.ReportService, a.BudgetService, a.RecurringExpenseService, a.ShoppingListService, a.TagService, a.PaymentMethodService, a.LoanService, a.ReceiptService, a.RecurringReceiptService) + lists := handler.NewListHandler(a.SpaceService, a.ShoppingListService) + tags := handler.NewTagHandler(a.SpaceService, a.TagService) + expenses := handler.NewExpenseHandler(a.SpaceService, a.ExpenseService, a.TagService, a.ShoppingListService, a.MoneyAccountService, a.PaymentMethodService) + accounts := handler.NewAccountHandler(a.SpaceService, a.MoneyAccountService, a.ExpenseService) + methods := handler.NewMethodHandler(a.SpaceService, a.PaymentMethodService) + recurring := handler.NewRecurringHandler(a.SpaceService, a.RecurringExpenseService, a.TagService, a.PaymentMethodService) + budgets := handler.NewBudgetHandler(a.SpaceService, a.BudgetService, a.TagService, a.ReportService) + spaceSettings := handler.NewSpaceSettingsHandler(a.SpaceService, a.InviteService) mux := http.NewServeMux() @@ -63,276 +81,100 @@ func SetupRoutes(a *app.App) http.Handler { // Space routes — wrapping order: Auth(SpaceAccess(handler)) // Auth runs first (outer), then SpaceAccess (inner), then the handler. - spaceOverviewHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.OverviewPage) - spaceOverviewWithAuth := middleware.RequireAuth(spaceOverviewHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}", spaceOverviewWithAuth) + sa := middleware.RequireSpaceAccess(a.SpaceService) + cl := crudLimiter - reportsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ReportsPage) - reportsPageWithAuth := middleware.RequireAuth(reportsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/reports", reportsPageWithAuth) + // Overview & Reports + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}", space.OverviewPage) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/reports", space.ReportsPage) - listsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ListsPage) - listsPageWithAuth := middleware.RequireAuth(listsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/lists", listsPageWithAuth) + // Shopping Lists + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/lists", lists.ListsPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/lists", lists.CreateList) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/lists/{listID}", lists.ListPage) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/lists/{listID}", lists.UpdateList) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/lists/{listID}", lists.DeleteList) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/lists/{listID}/items", lists.AddItemToList) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", lists.ToggleItem) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", lists.DeleteItem) - createListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateList) - createListWithAuth := middleware.RequireAuth(createListHandler) - mux.Handle("POST /app/spaces/{spaceID}/lists", crudLimiter(createListWithAuth)) + // Tags + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/tags", tags.TagsPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/tags", tags.CreateTag) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/tags/{tagID}", tags.DeleteTag) - listPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ListPage) - listPageWithAuth := middleware.RequireAuth(listPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/lists/{listID}", listPageWithAuth) + // Expenses + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/expenses", expenses.ExpensesPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/expenses", expenses.CreateExpense) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/expenses/{expenseID}", expenses.UpdateExpense) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/expenses/{expenseID}", expenses.DeleteExpense) - updateListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateList) - updateListWithAuth := middleware.RequireAuth(updateListHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/lists/{listID}", crudLimiter(updateListWithAuth)) + // Money Accounts + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/accounts", accounts.AccountsPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/accounts", accounts.CreateAccount) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/accounts/{accountID}", accounts.UpdateAccount) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/accounts/{accountID}", accounts.DeleteAccount) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/accounts/{accountID}/transfers", accounts.CreateTransfer) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", accounts.DeleteTransfer) - deleteListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteList) - deleteListWithAuth := middleware.RequireAuth(deleteListHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/lists/{listID}", crudLimiter(deleteListWithAuth)) + // Payment Methods + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/payment-methods", methods.PaymentMethodsPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/payment-methods", methods.CreatePaymentMethod) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/payment-methods/{methodID}", methods.UpdatePaymentMethod) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/payment-methods/{methodID}", methods.DeletePaymentMethod) - addItemHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.AddItemToList) - addItemWithAuth := middleware.RequireAuth(addItemHandler) - mux.Handle("POST /app/spaces/{spaceID}/lists/{listID}/items", crudLimiter(addItemWithAuth)) + // Recurring Expenses + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/recurring", recurring.RecurringExpensesPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/recurring", recurring.CreateRecurringExpense) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/recurring/{recurringID}", recurring.UpdateRecurringExpense) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/recurring/{recurringID}", recurring.DeleteRecurringExpense) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/recurring/{recurringID}/toggle", recurring.ToggleRecurringExpense) - toggleItemHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ToggleItem) - toggleItemWithAuth := middleware.RequireAuth(toggleItemHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", crudLimiter(toggleItemWithAuth)) + // Budgets + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/budgets", budgets.BudgetsPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/budgets", budgets.CreateBudget) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/budgets/{budgetID}", budgets.UpdateBudget) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/budgets/{budgetID}", budgets.DeleteBudget) - deleteItemHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteItem) - deleteItemWithAuth := middleware.RequireAuth(deleteItemHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/lists/{listID}/items/{itemID}", crudLimiter(deleteItemWithAuth)) + // Component routes (HTMX partial updates) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/budgets", budgets.GetBudgetsList) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/report-charts", budgets.GetReportCharts) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/transfer-history", accounts.GetTransferHistory) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/balance", expenses.GetBalanceCard) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/expenses", expenses.GetExpensesList) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/lists/{listID}/items", lists.GetShoppingListItems) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/lists/{listID}/card-items", lists.GetListCardItems) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/components/lists", lists.GetLists) - // Tag routes - tagsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.TagsPage) - tagsPageWithAuth := middleware.RequireAuth(tagsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/tags", tagsPageWithAuth) + // Space Settings + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/settings", spaceSettings.SettingsPage) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/settings/name", spaceSettings.UpdateSpaceName) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/settings/timezone", spaceSettings.UpdateSpaceTimezone) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/members/{userID}", spaceSettings.RemoveMember) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/invites/{token}", spaceSettings.CancelInvite) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/settings/invites", spaceSettings.GetPendingInvites) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/invites", spaceSettings.CreateInvite) - createTagHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateTag) - createTagWithAuth := middleware.RequireAuth(createTagHandler) - mux.Handle("POST /app/spaces/{spaceID}/tags", crudLimiter(createTagWithAuth)) + // Loans + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/loans", space.LoansPage) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/loans", space.CreateLoan) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/loans/{loanID}", space.LoanDetailPage) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/loans/{loanID}", space.UpdateLoan) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/loans/{loanID}", space.DeleteLoan) - deleteTagHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteTag) - deleteTagWithAuth := middleware.RequireAuth(deleteTagHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/tags/{tagID}", crudLimiter(deleteTagWithAuth)) + // Receipts + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/loans/{loanID}/receipts", space.CreateReceipt) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", space.UpdateReceipt) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", space.DeleteReceipt) + spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/loans/{loanID}/components/receipts", space.GetReceiptsList) - // Expense routes - expensesPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ExpensesPage) - expensesPageWithAuth := middleware.RequireAuth(expensesPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/expenses", expensesPageWithAuth) + // Recurring Receipts + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/loans/{loanID}/recurring", space.CreateRecurringReceipt) + spaceRouteLimited(mux, sa, cl, "PATCH /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", space.UpdateRecurringReceipt) + spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", space.DeleteRecurringReceipt) + spaceRouteLimited(mux, sa, cl, "POST /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}/toggle", space.ToggleRecurringReceipt) - createExpenseHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateExpense) - createExpenseWithAuth := middleware.RequireAuth(createExpenseHandler) - mux.Handle("POST /app/spaces/{spaceID}/expenses", crudLimiter(createExpenseWithAuth)) - - updateExpenseHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateExpense) - updateExpenseWithAuth := middleware.RequireAuth(updateExpenseHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", crudLimiter(updateExpenseWithAuth)) - - deleteExpenseHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteExpense) - deleteExpenseWithAuth := middleware.RequireAuth(deleteExpenseHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", crudLimiter(deleteExpenseWithAuth)) - - // Money Account routes - accountsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.AccountsPage) - accountsPageWithAuth := middleware.RequireAuth(accountsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/accounts", accountsPageWithAuth) - - createAccountHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateAccount) - createAccountWithAuth := middleware.RequireAuth(createAccountHandler) - mux.Handle("POST /app/spaces/{spaceID}/accounts", crudLimiter(createAccountWithAuth)) - - updateAccountHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateAccount) - updateAccountWithAuth := middleware.RequireAuth(updateAccountHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/accounts/{accountID}", crudLimiter(updateAccountWithAuth)) - - deleteAccountHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteAccount) - deleteAccountWithAuth := middleware.RequireAuth(deleteAccountHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}", crudLimiter(deleteAccountWithAuth)) - - createTransferHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateTransfer) - createTransferWithAuth := middleware.RequireAuth(createTransferHandler) - mux.Handle("POST /app/spaces/{spaceID}/accounts/{accountID}/transfers", crudLimiter(createTransferWithAuth)) - - deleteTransferHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteTransfer) - deleteTransferWithAuth := middleware.RequireAuth(deleteTransferHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", crudLimiter(deleteTransferWithAuth)) - - // Payment Method routes - methodsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.PaymentMethodsPage) - methodsPageWithAuth := middleware.RequireAuth(methodsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/payment-methods", methodsPageWithAuth) - - createMethodHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreatePaymentMethod) - createMethodWithAuth := middleware.RequireAuth(createMethodHandler) - mux.Handle("POST /app/spaces/{spaceID}/payment-methods", crudLimiter(createMethodWithAuth)) - - updateMethodHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdatePaymentMethod) - updateMethodWithAuth := middleware.RequireAuth(updateMethodHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/payment-methods/{methodID}", crudLimiter(updateMethodWithAuth)) - - deleteMethodHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeletePaymentMethod) - deleteMethodWithAuth := middleware.RequireAuth(deleteMethodHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/payment-methods/{methodID}", crudLimiter(deleteMethodWithAuth)) - - // Recurring expense routes - recurringPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.RecurringExpensesPage) - recurringPageWithAuth := middleware.RequireAuth(recurringPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/recurring", recurringPageWithAuth) - - createRecurringHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateRecurringExpense) - createRecurringWithAuth := middleware.RequireAuth(createRecurringHandler) - mux.Handle("POST /app/spaces/{spaceID}/recurring", crudLimiter(createRecurringWithAuth)) - - updateRecurringHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateRecurringExpense) - updateRecurringWithAuth := middleware.RequireAuth(updateRecurringHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/recurring/{recurringID}", crudLimiter(updateRecurringWithAuth)) - - deleteRecurringHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteRecurringExpense) - deleteRecurringWithAuth := middleware.RequireAuth(deleteRecurringHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/recurring/{recurringID}", crudLimiter(deleteRecurringWithAuth)) - - toggleRecurringHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ToggleRecurringExpense) - toggleRecurringWithAuth := middleware.RequireAuth(toggleRecurringHandler) - mux.Handle("POST /app/spaces/{spaceID}/recurring/{recurringID}/toggle", crudLimiter(toggleRecurringWithAuth)) - - // Budget routes - budgetsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.BudgetsPage) - budgetsPageWithAuth := middleware.RequireAuth(budgetsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/budgets", budgetsPageWithAuth) - - createBudgetHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateBudget) - createBudgetWithAuth := middleware.RequireAuth(createBudgetHandler) - mux.Handle("POST /app/spaces/{spaceID}/budgets", crudLimiter(createBudgetWithAuth)) - - updateBudgetHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateBudget) - updateBudgetWithAuth := middleware.RequireAuth(updateBudgetHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/budgets/{budgetID}", crudLimiter(updateBudgetWithAuth)) - - deleteBudgetHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteBudget) - deleteBudgetWithAuth := middleware.RequireAuth(deleteBudgetHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/budgets/{budgetID}", crudLimiter(deleteBudgetWithAuth)) - - budgetsListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetBudgetsList) - budgetsListWithAuth := middleware.RequireAuth(budgetsListHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/budgets", budgetsListWithAuth) - - // Loan routes - loansPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.LoansPage) - loansPageWithAuth := middleware.RequireAuth(loansPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/loans", loansPageWithAuth) - - createLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateLoan) - createLoanWithAuth := middleware.RequireAuth(createLoanHandler) - mux.Handle("POST /app/spaces/{spaceID}/loans", crudLimiter(createLoanWithAuth)) - - loanDetailHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.LoanDetailPage) - loanDetailWithAuth := middleware.RequireAuth(loanDetailHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/loans/{loanID}", loanDetailWithAuth) - - updateLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateLoan) - updateLoanWithAuth := middleware.RequireAuth(updateLoanHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}", crudLimiter(updateLoanWithAuth)) - - deleteLoanHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteLoan) - deleteLoanWithAuth := middleware.RequireAuth(deleteLoanHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}", crudLimiter(deleteLoanWithAuth)) - - // Receipt routes - createReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateReceipt) - createReceiptWithAuth := middleware.RequireAuth(createReceiptHandler) - mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/receipts", crudLimiter(createReceiptWithAuth)) - - updateReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateReceipt) - updateReceiptWithAuth := middleware.RequireAuth(updateReceiptHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", crudLimiter(updateReceiptWithAuth)) - - deleteReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteReceipt) - deleteReceiptWithAuth := middleware.RequireAuth(deleteReceiptHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}/receipts/{receiptID}", crudLimiter(deleteReceiptWithAuth)) - - receiptsListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetReceiptsList) - receiptsListWithAuth := middleware.RequireAuth(receiptsListHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/loans/{loanID}/components/receipts", receiptsListWithAuth) - - // Recurring receipt routes - createRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateRecurringReceipt) - createRecurringReceiptWithAuth := middleware.RequireAuth(createRecurringReceiptHandler) - mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/recurring", crudLimiter(createRecurringReceiptWithAuth)) - - updateRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateRecurringReceipt) - updateRecurringReceiptWithAuth := middleware.RequireAuth(updateRecurringReceiptHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", crudLimiter(updateRecurringReceiptWithAuth)) - - deleteRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.DeleteRecurringReceipt) - deleteRecurringReceiptWithAuth := middleware.RequireAuth(deleteRecurringReceiptHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}", crudLimiter(deleteRecurringReceiptWithAuth)) - - toggleRecurringReceiptHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.ToggleRecurringReceipt) - toggleRecurringReceiptWithAuth := middleware.RequireAuth(toggleRecurringReceiptHandler) - mux.Handle("POST /app/spaces/{spaceID}/loans/{loanID}/recurring/{recurringReceiptID}/toggle", crudLimiter(toggleRecurringReceiptWithAuth)) - - // Report routes - reportChartsHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetReportCharts) - reportChartsWithAuth := middleware.RequireAuth(reportChartsHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/report-charts", reportChartsWithAuth) - - // Component routes (HTMX updates) - transferHistoryHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetTransferHistory) - transferHistoryWithAuth := middleware.RequireAuth(transferHistoryHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/transfer-history", transferHistoryWithAuth) - - balanceCardHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetBalanceCard) - balanceCardWithAuth := middleware.RequireAuth(balanceCardHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAuth) - - expensesListHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetExpensesList) - expensesListWithAuth := middleware.RequireAuth(expensesListHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/expenses", expensesListWithAuth) - - shoppingListItemsHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetShoppingListItems) - shoppingListItemsWithAuth := middleware.RequireAuth(shoppingListItemsHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/lists/{listID}/items", shoppingListItemsWithAuth) - - cardItemsHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetListCardItems) - cardItemsWithAuth := middleware.RequireAuth(cardItemsHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/lists/{listID}/card-items", cardItemsWithAuth) - - listsComponentHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetLists) - listsComponentWithAuth := middleware.RequireAuth(listsComponentHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAuth) - - // Settings routes - settingsPageHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.SettingsPage) - settingsPageWithAuth := middleware.RequireAuth(settingsPageHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/settings", settingsPageWithAuth) - - updateSpaceNameHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateSpaceName) - updateSpaceNameWithAuth := middleware.RequireAuth(updateSpaceNameHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", crudLimiter(updateSpaceNameWithAuth)) - - updateSpaceTimezoneHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.UpdateSpaceTimezone) - updateSpaceTimezoneWithAuth := middleware.RequireAuth(updateSpaceTimezoneHandler) - mux.Handle("PATCH /app/spaces/{spaceID}/settings/timezone", crudLimiter(updateSpaceTimezoneWithAuth)) - - removeMemberHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.RemoveMember) - removeMemberWithAuth := middleware.RequireAuth(removeMemberHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", crudLimiter(removeMemberWithAuth)) - - cancelInviteHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CancelInvite) - cancelInviteWithAuth := middleware.RequireAuth(cancelInviteHandler) - mux.Handle("DELETE /app/spaces/{spaceID}/invites/{token}", crudLimiter(cancelInviteWithAuth)) - - getPendingInvitesHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetPendingInvites) - getPendingInvitesWithAuth := middleware.RequireAuth(getPendingInvitesHandler) - mux.HandleFunc("GET /app/spaces/{spaceID}/settings/invites", getPendingInvitesWithAuth) - - // Invite routes - createInviteHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.CreateInvite) - createInviteWithAuth := middleware.RequireAuth(createInviteHandler) - mux.Handle("POST /app/spaces/{spaceID}/invites", crudLimiter(createInviteWithAuth)) - - mux.HandleFunc("GET /join/{token}", space.JoinSpace) + mux.HandleFunc("GET /join/{token}", spaceSettings.JoinSpace) // 404 mux.HandleFunc("/{path...}", home.NotFoundPage) diff --git a/internal/service/expense.go b/internal/service/expense.go index 9be7ebc..854bffc 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -132,18 +132,7 @@ func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, pa return nil, 0, err } - totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage - if totalPages < 1 { - totalPages = 1 - } - if page < 1 { - page = 1 - } - if page > totalPages { - page = totalPages - } - - offset := (page - 1) * ExpensesPerPage + page, totalPages, offset := Paginate(page, total, ExpensesPerPage) expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset) if err != nil { return nil, 0, err @@ -175,18 +164,7 @@ func (s *ExpenseService) GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID return nil, 0, err } - totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage - if totalPages < 1 { - totalPages = 1 - } - if page < 1 { - page = 1 - } - if page > totalPages { - page = totalPages - } - - offset := (page - 1) * ExpensesPerPage + page, totalPages, offset := Paginate(page, total, ExpensesPerPage) expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset) if err != nil { return nil, 0, err diff --git a/internal/service/money_account.go b/internal/service/money_account.go index 865eb32..004ce84 100644 --- a/internal/service/money_account.go +++ b/internal/service/money_account.go @@ -180,18 +180,7 @@ func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page return nil, 0, err } - totalPages := (total + TransfersPerPage - 1) / TransfersPerPage - if totalPages < 1 { - totalPages = 1 - } - if page < 1 { - page = 1 - } - if page > totalPages { - page = totalPages - } - - offset := (page - 1) * TransfersPerPage + page, totalPages, offset := Paginate(page, total, TransfersPerPage) transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset) if err != nil { return nil, 0, err diff --git a/internal/service/pagination.go b/internal/service/pagination.go new file mode 100644 index 0000000..6904d11 --- /dev/null +++ b/internal/service/pagination.go @@ -0,0 +1,18 @@ +package service + +// Paginate calculates pagination values from a page number, total count, and page size. +// Returns the adjusted page, total pages, and offset for the query. +func Paginate(page, total, perPage int) (adjustedPage, totalPages, offset int) { + totalPages = (total + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + if page < 1 { + page = 1 + } + if page > totalPages { + page = totalPages + } + offset = (page - 1) * perPage + return page, totalPages, offset +} diff --git a/internal/service/recurring_deposit.go b/internal/service/recurring_deposit.go deleted file mode 100644 index 50605d5..0000000 --- a/internal/service/recurring_deposit.go +++ /dev/null @@ -1,284 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "strings" - "time" - - "git.juancwu.dev/juancwu/budgit/internal/model" - "git.juancwu.dev/juancwu/budgit/internal/repository" - "github.com/google/uuid" -) - -type CreateRecurringDepositDTO struct { - SpaceID string - AccountID string - Amount int - Frequency model.Frequency - StartDate time.Time - EndDate *time.Time - Title string - CreatedBy string -} - -type UpdateRecurringDepositDTO struct { - ID string - AccountID string - Amount int - Frequency model.Frequency - StartDate time.Time - EndDate *time.Time - Title string -} - -type RecurringDepositService struct { - recurringRepo repository.RecurringDepositRepository - accountRepo repository.MoneyAccountRepository - expenseService *ExpenseService - profileRepo repository.ProfileRepository - spaceRepo repository.SpaceRepository -} - -func NewRecurringDepositService(recurringRepo repository.RecurringDepositRepository, accountRepo repository.MoneyAccountRepository, expenseService *ExpenseService, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringDepositService { - return &RecurringDepositService{ - recurringRepo: recurringRepo, - accountRepo: accountRepo, - expenseService: expenseService, - profileRepo: profileRepo, - spaceRepo: spaceRepo, - } -} - -func (s *RecurringDepositService) CreateRecurringDeposit(dto CreateRecurringDepositDTO) (*model.RecurringDeposit, error) { - if dto.Amount <= 0 { - return nil, fmt.Errorf("amount must be positive") - } - - now := time.Now() - rd := &model.RecurringDeposit{ - ID: uuid.NewString(), - SpaceID: dto.SpaceID, - AccountID: dto.AccountID, - AmountCents: dto.Amount, - Frequency: dto.Frequency, - StartDate: dto.StartDate, - EndDate: dto.EndDate, - NextOccurrence: dto.StartDate, - IsActive: true, - Title: strings.TrimSpace(dto.Title), - CreatedBy: dto.CreatedBy, - CreatedAt: now, - UpdatedAt: now, - } - - if err := s.recurringRepo.Create(rd); err != nil { - return nil, err - } - return rd, nil -} - -func (s *RecurringDepositService) GetRecurringDeposit(id string) (*model.RecurringDeposit, error) { - return s.recurringRepo.GetByID(id) -} - -func (s *RecurringDepositService) GetRecurringDepositsForSpace(spaceID string) ([]*model.RecurringDeposit, error) { - return s.recurringRepo.GetBySpaceID(spaceID) -} - -func (s *RecurringDepositService) GetRecurringDepositsWithAccountsForSpace(spaceID string) ([]*model.RecurringDepositWithAccount, error) { - deposits, err := s.recurringRepo.GetBySpaceID(spaceID) - if err != nil { - return nil, err - } - - accounts, err := s.accountRepo.GetBySpaceID(spaceID) - if err != nil { - return nil, err - } - - accountNames := make(map[string]string, len(accounts)) - for _, acct := range accounts { - accountNames[acct.ID] = acct.Name - } - - result := make([]*model.RecurringDepositWithAccount, len(deposits)) - for i, rd := range deposits { - result[i] = &model.RecurringDepositWithAccount{ - RecurringDeposit: *rd, - AccountName: accountNames[rd.AccountID], - } - } - return result, nil -} - -func (s *RecurringDepositService) UpdateRecurringDeposit(dto UpdateRecurringDepositDTO) (*model.RecurringDeposit, error) { - if dto.Amount <= 0 { - return nil, fmt.Errorf("amount must be positive") - } - - existing, err := s.recurringRepo.GetByID(dto.ID) - if err != nil { - return nil, err - } - - existing.AccountID = dto.AccountID - existing.AmountCents = dto.Amount - existing.Frequency = dto.Frequency - existing.StartDate = dto.StartDate - existing.EndDate = dto.EndDate - existing.Title = strings.TrimSpace(dto.Title) - existing.UpdatedAt = time.Now() - - // Recalculate next occurrence if start date moved forward - if existing.NextOccurrence.Before(dto.StartDate) { - existing.NextOccurrence = dto.StartDate - } - - if err := s.recurringRepo.Update(existing); err != nil { - return nil, err - } - return existing, nil -} - -func (s *RecurringDepositService) DeleteRecurringDeposit(id string) error { - return s.recurringRepo.Delete(id) -} - -func (s *RecurringDepositService) ToggleRecurringDeposit(id string) (*model.RecurringDeposit, error) { - rd, err := s.recurringRepo.GetByID(id) - if err != nil { - return nil, err - } - - newActive := !rd.IsActive - if err := s.recurringRepo.SetActive(id, newActive); err != nil { - return nil, err - } - rd.IsActive = newActive - return rd, nil -} - -func (s *RecurringDepositService) ProcessDueRecurrences(now time.Time) error { - dues, err := s.recurringRepo.GetDueRecurrences(now) - if err != nil { - return fmt.Errorf("failed to get due recurring deposits: %w", err) - } - - tzCache := make(map[string]*time.Location) - for _, rd := range dues { - localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache) - if err := s.processRecurrence(rd, localNow); err != nil { - slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err) - } - } - return nil -} - -func (s *RecurringDepositService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error { - dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now) - if err != nil { - return fmt.Errorf("failed to get due recurring deposits for space: %w", err) - } - - tzCache := make(map[string]*time.Location) - for _, rd := range dues { - localNow := s.getLocalNow(rd.SpaceID, rd.CreatedBy, now, tzCache) - if err := s.processRecurrence(rd, localNow); err != nil { - slog.Error("failed to process recurring deposit", "id", rd.ID, "error", err) - } - } - return nil -} - -// getLocalNow resolves the effective timezone for a recurring deposit. -// Resolution order: space timezone → user profile timezone → UTC. -func (s *RecurringDepositService) getLocalNow(spaceID, userID string, now time.Time, cache map[string]*time.Location) time.Time { - spaceKey := "space:" + spaceID - if loc, ok := cache[spaceKey]; ok { - return now.In(loc) - } - - space, err := s.spaceRepo.ByID(spaceID) - if err == nil && space != nil { - if loc := space.Location(); loc != nil { - cache[spaceKey] = loc - return now.In(loc) - } - } - - userKey := "user:" + userID - if loc, ok := cache[userKey]; ok { - return now.In(loc) - } - - loc := time.UTC - profile, err := s.profileRepo.ByUserID(userID) - if err == nil && profile != nil { - loc = profile.Location() - } - cache[userKey] = loc - return now.In(loc) -} - -func (s *RecurringDepositService) getAvailableBalance(spaceID string) (int, error) { - totalBalance, err := s.expenseService.GetBalanceForSpace(spaceID) - if err != nil { - return 0, fmt.Errorf("failed to get space balance: %w", err) - } - totalAllocated, err := s.accountRepo.GetTotalAllocatedForSpace(spaceID) - if err != nil { - return 0, fmt.Errorf("failed to get total allocated: %w", err) - } - return totalBalance - totalAllocated, nil -} - -func (s *RecurringDepositService) processRecurrence(rd *model.RecurringDeposit, now time.Time) error { - for !rd.NextOccurrence.After(now) { - // Check if end_date has been passed - if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) { - return s.recurringRepo.Deactivate(rd.ID) - } - - // Check available balance - availableBalance, err := s.getAvailableBalance(rd.SpaceID) - if err != nil { - return err - } - - if availableBalance >= rd.AmountCents { - rdID := rd.ID - transfer := &model.AccountTransfer{ - ID: uuid.NewString(), - AccountID: rd.AccountID, - AmountCents: rd.AmountCents, - Direction: model.TransferDirectionDeposit, - Note: rd.Title, - RecurringDepositID: &rdID, - CreatedBy: rd.CreatedBy, - CreatedAt: time.Now(), - } - if err := s.accountRepo.CreateTransfer(transfer); err != nil { - return fmt.Errorf("failed to create deposit transfer: %w", err) - } - } else { - slog.Warn("recurring deposit skipped: insufficient available balance", - "recurring_deposit_id", rd.ID, - "space_id", rd.SpaceID, - "needed", rd.AmountCents, - "available", availableBalance, - ) - } - - rd.NextOccurrence = AdvanceDate(rd.NextOccurrence, rd.Frequency) - } - - // Check if the new next occurrence exceeds end date - if rd.EndDate != nil && rd.NextOccurrence.After(*rd.EndDate) { - if err := s.recurringRepo.Deactivate(rd.ID); err != nil { - return err - } - } - - return s.recurringRepo.UpdateNextOccurrence(rd.ID, rd.NextOccurrence) -} diff --git a/internal/service/shopping_list.go b/internal/service/shopping_list.go index 1f3e7a9..442eaa8 100644 --- a/internal/service/shopping_list.go +++ b/internal/service/shopping_list.go @@ -127,18 +127,7 @@ func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int) return nil, 0, err } - totalPages := (total + ItemsPerCardPage - 1) / ItemsPerCardPage - if totalPages < 1 { - totalPages = 1 - } - if page < 1 { - page = 1 - } - if page > totalPages { - page = totalPages - } - - offset := (page - 1) * ItemsPerCardPage + page, totalPages, offset := Paginate(page, total, ItemsPerCardPage) items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset) if err != nil { return nil, 0, err diff --git a/internal/ui/components/moneyaccount/moneyaccount.templ b/internal/ui/components/moneyaccount/moneyaccount.templ index 29cbdc3..a482164 100644 --- a/internal/ui/components/moneyaccount/moneyaccount.templ +++ b/internal/ui/components/moneyaccount/moneyaccount.templ @@ -6,32 +6,13 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox" ) -func frequencyLabel(f model.Frequency) string { - switch f { - case model.FrequencyDaily: - return "Daily" - case model.FrequencyWeekly: - return "Weekly" - case model.FrequencyBiweekly: - return "Biweekly" - case model.FrequencyMonthly: - return "Monthly" - case model.FrequencyYearly: - return "Yearly" - default: - return string(f) - } -} - templ BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, oob bool) {
} -templ RecurringDepositsSection(spaceID string, deposits []*model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) { -
-
-

Recurring Deposits

- if len(accounts) > 0 { - @dialog.Dialog(dialog.Props{ID: "add-recurring-deposit-dialog"}) { - @dialog.Trigger() { - @button.Button() { - Add - } - } - @dialog.Content() { - @dialog.Header() { - @dialog.Title() { - Add Recurring Deposit - } - @dialog.Description() { - Automatically deposit into an account on a schedule. - } - } - @AddRecurringDepositForm(spaceID, accounts, "add-recurring-deposit-dialog") - } - } - } -
-
-
- if len(deposits) == 0 { -

No recurring deposits set up yet.

- } - for _, rd := range deposits { - @RecurringDepositItem(spaceID, rd, accounts) - } -
-
-
-} - -templ RecurringDepositItem(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) { - {{ editDialogID := "edit-rd-" + rd.ID }} - {{ delDialogID := "del-rd-" + rd.ID }} -
-
-
- - if rd.Title != "" { - { rd.Title } - } else { - Deposit to { rd.AccountName } - } - - - { frequencyLabel(rd.Frequency) } - - if !rd.IsActive { - - Paused - - } -
-
- { rd.AccountName } - · - Next: { rd.NextOccurrence.Format("Jan 2, 2006") } -
-
-
- - +{ fmt.Sprintf("$%.2f", float64(rd.AmountCents)/100.0) } - - // Toggle - @button.Button(button.Props{ - Variant: button.VariantGhost, - Size: button.SizeIcon, - Class: "size-7", - Attributes: templ.Attributes{ - "hx-post": fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s/toggle", spaceID, rd.ID), - "hx-target": "#recurring-deposit-" + rd.ID, - "hx-swap": "outerHTML", - }, - }) { - if rd.IsActive { - @icon.Pause(icon.Props{Size: 14}) - } else { - @icon.Play(icon.Props{Size: 14}) - } - } - // Edit - @dialog.Dialog(dialog.Props{ID: editDialogID}) { - @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 Recurring Deposit - } - @dialog.Description() { - Update the recurring deposit settings. - } - } - @EditRecurringDepositForm(spaceID, rd, accounts, editDialogID) - } - } - // Delete - @dialog.Dialog(dialog.Props{ID: delDialogID}) { - @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 Recurring Deposit - } - @dialog.Description() { - Are you sure? This will not affect past deposits already made. - } - } - @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/accounts/recurring/%s", spaceID, rd.ID), - "hx-target": "#recurring-deposit-" + rd.ID, - "hx-swap": "delete", - }, - }) { - Delete - } - } - } - } -
-
-} - -templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithBalance, dialogID string) { -
- @csrf.Token() - // Account -
- @label.Label(label.Props{}) { - Account - } - @selectbox.SelectBox(selectbox.Props{ID: "rd-account"}) { - @selectbox.Trigger(selectbox.TriggerProps{Name: "account_id"}) { - @selectbox.Value() - } - @selectbox.Content(selectbox.ContentProps{NoSearch: true}) { - for i, acct := range accounts { - @selectbox.Item(selectbox.ItemProps{Value: acct.ID, Selected: i == 0}) { - { acct.Name } - } - } - } - } -
- // Amount -
- @label.Label(label.Props{For: "rd-amount"}) { - Amount - } - @input.Input(input.Props{ - Name: "amount", - ID: "rd-amount", - Type: "number", - Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"}, - }) -
- // Frequency -
- @label.Label(label.Props{}) { - Frequency - } - @selectbox.SelectBox(selectbox.Props{ID: "rd-frequency"}) { - @selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) { - @selectbox.Value() - } - @selectbox.Content(selectbox.ContentProps{NoSearch: true}) { - @selectbox.Item(selectbox.ItemProps{Value: "daily"}) { - Daily - } - @selectbox.Item(selectbox.ItemProps{Value: "weekly"}) { - Weekly - } - @selectbox.Item(selectbox.ItemProps{Value: "biweekly"}) { - Biweekly - } - @selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: true}) { - Monthly - } - @selectbox.Item(selectbox.ItemProps{Value: "yearly"}) { - Yearly - } - } - } -
- // Start Date -
- @label.Label(label.Props{For: "rd-start-date"}) { - Start Date - } - @datepicker.DatePicker(datepicker.Props{ - ID: "rd-start-date", - Name: "start_date", - Attributes: templ.Attributes{"required": "true"}, - }) -
- // End Date (optional) -
- @label.Label(label.Props{For: "rd-end-date"}) { - End Date (optional) - } - @datepicker.DatePicker(datepicker.Props{ - ID: "rd-end-date", - Name: "end_date", - Clearable: true, - }) -
- // Title (optional) -
- @label.Label(label.Props{For: "rd-title"}) { - Title (optional) - } - @input.Input(input.Props{ - Name: "title", - ID: "rd-title", - Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"}, - }) -
-
- @button.Submit() { - Save - } -
-
-} - templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {

Transfer History

@@ -625,9 +350,7 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) { Withdrawal }

- if t.RecurringDepositID != nil { - @icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"}) - } +

{ t.CreatedAt.Format("Jan 2, 2006") } · { t.AccountName } @@ -660,123 +383,3 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {

} -templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) { -
- @csrf.Token() - // Account -
- @label.Label(label.Props{}) { - Account - } - @selectbox.SelectBox(selectbox.Props{ID: "edit-rd-account-" + rd.ID}) { - @selectbox.Trigger(selectbox.TriggerProps{Name: "account_id"}) { - @selectbox.Value() - } - @selectbox.Content(selectbox.ContentProps{NoSearch: true}) { - for _, acct := range accounts { - @selectbox.Item(selectbox.ItemProps{Value: acct.ID, Selected: acct.ID == rd.AccountID}) { - { acct.Name } - } - } - } - } -
- // Amount -
- @label.Label(label.Props{For: "edit-rd-amount-" + rd.ID}) { - Amount - } - @input.Input(input.Props{ - Name: "amount", - ID: "edit-rd-amount-" + rd.ID, - Type: "number", - Value: fmt.Sprintf("%.2f", float64(rd.AmountCents)/100.0), - Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"}, - }) -
- // Frequency -
- @label.Label(label.Props{}) { - Frequency - } - @selectbox.SelectBox(selectbox.Props{ID: "edit-rd-frequency-" + rd.ID}) { - @selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) { - @selectbox.Value() - } - @selectbox.Content(selectbox.ContentProps{NoSearch: true}) { - @selectbox.Item(selectbox.ItemProps{Value: "daily", Selected: rd.Frequency == model.FrequencyDaily}) { - Daily - } - @selectbox.Item(selectbox.ItemProps{Value: "weekly", Selected: rd.Frequency == model.FrequencyWeekly}) { - Weekly - } - @selectbox.Item(selectbox.ItemProps{Value: "biweekly", Selected: rd.Frequency == model.FrequencyBiweekly}) { - Biweekly - } - @selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: rd.Frequency == model.FrequencyMonthly}) { - Monthly - } - @selectbox.Item(selectbox.ItemProps{Value: "yearly", Selected: rd.Frequency == model.FrequencyYearly}) { - Yearly - } - } - } -
- // Start Date -
- @label.Label(label.Props{For: "edit-rd-start-date-" + rd.ID}) { - Start Date - } - @datepicker.DatePicker(datepicker.Props{ - ID: "edit-rd-start-date-" + rd.ID, - Name: "start_date", - Value: rd.StartDate, - Required: true, - Clearable: true, - }) -
- // End Date (optional) -
- @label.Label(label.Props{For: "edit-rd-end-date-" + rd.ID}) { - End Date (optional) - } - if rd.EndDate != nil { - @datepicker.DatePicker(datepicker.Props{ - ID: "edit-rd-end-date-" + rd.ID, - Name: "end_date", - Value: *rd.EndDate, - Clearable: true, - }) - } else { - @datepicker.DatePicker(datepicker.Props{ - ID: "edit-rd-end-date-" + rd.ID, - Name: "end_date", - Clearable: true, - }) - } -
- // Title (optional) -
- @label.Label(label.Props{For: "edit-rd-title-" + rd.ID}) { - Title (optional) - } - @input.Input(input.Props{ - Name: "title", - ID: "edit-rd-title-" + rd.ID, - Value: rd.Title, - Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"}, - }) -
-
- @button.Submit() { - Save - } -
-
-}