chore: refactor
All checks were successful
Deploy / build-and-deploy (push) Successful in 3m45s

This commit is contained in:
juancwu 2026-03-14 16:27:45 +00:00
commit 45fcecdc04
29 changed files with 2865 additions and 3867 deletions

View file

@ -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))
}

View file

@ -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) {

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,
}))
}

View file

@ -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))
}

File diff suppressed because it is too large Load diff

View file

@ -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)
}

View file

@ -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")

View file

@ -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,
}))
}