This commit is contained in:
parent
13774eec7d
commit
45fcecdc04
29 changed files with 2865 additions and 3867 deletions
|
|
@ -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,
|
||||
|
|
|
|||
363
internal/handler/account_handler.go
Normal file
363
internal/handler/account_handler.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
311
internal/handler/budget_handler.go
Normal file
311
internal/handler/budget_handler.go
Normal 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))
|
||||
}
|
||||
493
internal/handler/expense_handler.go
Normal file
493
internal/handler/expense_handler.go
Normal 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))
|
||||
}
|
||||
49
internal/handler/helpers.go
Normal file
49
internal/handler/helpers.go
Normal 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
|
||||
}
|
||||
353
internal/handler/list_handler.go
Normal file
353
internal/handler/list_handler.go
Normal 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
|
||||
}
|
||||
143
internal/handler/method_handler.go
Normal file
143
internal/handler/method_handler.go
Normal 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,
|
||||
}))
|
||||
}
|
||||
371
internal/handler/recurring_handler.go
Normal file
371
internal/handler/recurring_handler.go
Normal 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
300
internal/handler/space_settings_handler.go
Normal file
300
internal/handler/space_settings_handler.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
107
internal/handler/tag_handler.go
Normal file
107
internal/handler/tag_handler.go
Normal 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
18
internal/repository/helpers.go
Normal file
18
internal/repository/helpers.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
internal/service/pagination.go
Normal file
18
internal/service/pagination.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>·</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") } · { 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>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue