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

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

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

View file

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

View file

@ -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
return func(w http.ResponseWriter, r *http.Request) {
redirect(w, r, path, http.StatusSeeOther)
}
http.Redirect(w, r, path, http.StatusSeeOther)
})
}

View file

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

View file

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

View file

@ -32,16 +32,10 @@ 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);`
_, 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 {
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
}
@ -54,7 +48,8 @@ func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error {
}
}
return tx.Commit()
return nil
})
}
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
@ -136,19 +131,12 @@ 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;`
_, err = tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
if err != nil {
if _, err := tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, 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
}
@ -162,10 +150,18 @@ func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
}
}
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
}

View file

@ -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()
// Insert Expense
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)
_, 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 {
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 {
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,21 +214,13 @@ 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;`
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID)
if err != nil {
if _, err := tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, 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 {
if _, err := tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID); err != nil {
return err
}
@ -250,11 +233,19 @@ func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) erro
}
}
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
}

View file

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

View file

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

View file

@ -37,16 +37,10 @@ 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);`
_, 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 {
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
}
@ -59,7 +53,8 @@ func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs [
}
}
return tx.Commit()
return nil
})
}
func (r *recurringExpenseRepository) GetByID(id string) (*model.RecurringExpense, error) {
@ -165,20 +160,13 @@ 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;`
_, 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 {
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
}
_, err = tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID)
if err != nil {
if _, err := tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID); err != nil {
return err
}
@ -191,11 +179,19 @@ func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs [
}
}
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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
<div
id="accounts-balance-summary"
@ -285,262 +266,6 @@ templ TransferForm(spaceID string, accountID string, direction model.TransferDir
</form>
}
templ RecurringDepositsSection(spaceID string, deposits []*model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) {
<div class="space-y-4 mt-8">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">Recurring Deposits</h2>
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")
}
}
}
</div>
<div class="border rounded-lg">
<div id="recurring-deposits-list" class="divide-y">
if len(deposits) == 0 {
<p class="p-4 text-sm text-muted-foreground">No recurring deposits set up yet.</p>
}
for _, rd := range deposits {
@RecurringDepositItem(spaceID, rd, accounts)
}
</div>
</div>
</div>
}
templ RecurringDepositItem(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance) {
{{ editDialogID := "edit-rd-" + rd.ID }}
{{ delDialogID := "del-rd-" + rd.ID }}
<div
id={ "recurring-deposit-" + rd.ID }
class={ "flex items-center justify-between p-4 gap-4", templ.KV("opacity-50", !rd.IsActive) }
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium truncate">
if rd.Title != "" {
{ rd.Title }
} else {
Deposit to { rd.AccountName }
}
</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{ frequencyLabel(rd.Frequency) }
</span>
if !rd.IsActive {
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
Paused
</span>
}
</div>
<div class="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{ rd.AccountName }</span>
<span>&middot;</span>
<span>Next: { rd.NextOccurrence.Format("Jan 2, 2006") }</span>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(rd.AmountCents)/100.0) }
</span>
// 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
}
}
}
}
</div>
</div>
}
templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithBalance, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/accounts/recurring" }
hx-target="#recurring-deposits-list"
hx-swap="beforeend"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
// Account
<div>
@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 }
}
}
}
}
</div>
// Amount
<div>
@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"},
})
</div>
// Frequency
<div>
@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
}
}
}
</div>
// Start Date
<div>
@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"},
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "rd-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "rd-end-date",
Name: "end_date",
Clearable: true,
})
</div>
// Title (optional)
<div>
@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"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
<div class="space-y-4 mt-8">
<h2 class="text-xl font-bold">Transfer History</h2>
@ -625,9 +350,7 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
Withdrawal
}
</p>
if t.RecurringDepositID != nil {
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
}
</div>
<p class="text-sm text-muted-foreground">
{ t.CreatedAt.Format("Jan 2, 2006") } &middot; { t.AccountName }
@ -660,123 +383,3 @@ templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
</div>
}
templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) {
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID) }
hx-target={ "#recurring-deposit-" + rd.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Account
<div>
@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 }
}
}
}
}
</div>
// Amount
<div>
@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"},
})
</div>
// Frequency
<div>
@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
}
}
}
</div>
// Start Date
<div>
@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,
})
</div>
// End Date (optional)
<div>
@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,
})
}
</div>
// Title (optional)
<div>
@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"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}