chore: massive reset

This commit is contained in:
juancwu 2026-04-06 17:51:59 +00:00
commit df164ab0f4
96 changed files with 198 additions and 15405 deletions

View file

@ -11,25 +11,13 @@ import (
)
type App struct {
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
ProfileService *service.ProfileService
SpaceService *service.SpaceService
TagService *service.TagService
ShoppingListService *service.ShoppingListService
ExpenseService *service.ExpenseService
InviteService *service.InviteService
MoneyAccountService *service.MoneyAccountService
PaymentMethodService *service.PaymentMethodService
RecurringExpenseService *service.RecurringExpenseService
BudgetService *service.BudgetService
ReportService *service.ReportService
LoanService *service.LoanService
ReceiptService *service.ReceiptService
RecurringReceiptService *service.RecurringReceiptService
Cfg *config.Config
DB *sqlx.DB
UserService *service.UserService
AuthService *service.AuthService
EmailService *service.EmailService
SpaceService *service.SpaceService
InviteService *service.InviteService
}
func New(cfg *config.Config) (*App, error) {
@ -47,21 +35,9 @@ func New(cfg *config.Config) (*App, error) {
// Repositories
userRepository := repository.NewUserRepository(database)
profileRepository := repository.NewProfileRepository(database)
tokenRepository := repository.NewTokenRepository(database)
spaceRepository := repository.NewSpaceRepository(database)
tagRepository := repository.NewTagRepository(database)
shoppingListRepository := repository.NewShoppingListRepository(database)
listItemRepository := repository.NewListItemRepository(database)
expenseRepository := repository.NewExpenseRepository(database)
invitationRepository := repository.NewInvitationRepository(database)
moneyAccountRepository := repository.NewMoneyAccountRepository(database)
paymentMethodRepository := repository.NewPaymentMethodRepository(database)
recurringExpenseRepository := repository.NewRecurringExpenseRepository(database)
budgetRepository := repository.NewBudgetRepository(database)
loanRepository := repository.NewLoanRepository(database)
receiptRepository := repository.NewReceiptRepository(database)
recurringReceiptRepository := repository.NewRecurringReceiptRepository(database)
// Services
userService := service.NewUserService(userRepository)
@ -76,7 +52,6 @@ func New(cfg *config.Config) (*App, error) {
authService := service.NewAuthService(
emailService,
userRepository,
profileRepository,
tokenRepository,
spaceService,
cfg.JWTSecret,
@ -84,42 +59,19 @@ func New(cfg *config.Config) (*App, error) {
cfg.TokenMagicLinkExpiry,
cfg.IsProduction(),
)
profileService := service.NewProfileService(profileRepository)
tagService := service.NewTagService(tagRepository)
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
expenseService := service.NewExpenseService(expenseRepository)
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
recurringExpenseService := service.NewRecurringExpenseService(recurringExpenseRepository, expenseRepository, profileRepository, spaceRepository)
budgetService := service.NewBudgetService(budgetRepository)
reportService := service.NewReportService(expenseRepository)
loanService := service.NewLoanService(loanRepository, receiptRepository)
receiptService := service.NewReceiptService(receiptRepository, loanRepository, moneyAccountRepository)
recurringReceiptService := service.NewRecurringReceiptService(recurringReceiptRepository, receiptService, loanRepository, profileRepository, spaceRepository)
return &App{
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
ProfileService: profileService,
SpaceService: spaceService,
TagService: tagService,
ShoppingListService: shoppingListService,
ExpenseService: expenseService,
InviteService: inviteService,
MoneyAccountService: moneyAccountService,
PaymentMethodService: paymentMethodService,
RecurringExpenseService: recurringExpenseService,
BudgetService: budgetService,
ReportService: reportService,
LoanService: loanService,
ReceiptService: receiptService,
RecurringReceiptService: recurringReceiptService,
Cfg: cfg,
DB: database,
UserService: userService,
AuthService: authService,
EmailService: emailService,
SpaceService: spaceService,
InviteService: inviteService,
}, nil
}
func (a *App) Close() error {
if a.DB != nil {
return a.DB.Close()

View file

@ -9,7 +9,7 @@ import (
const (
UserKey string = "user"
ProfileKey string = "profile"
URLPathKey string = "url_path"
ConfigKey string = "config"
CSRFTokenKey string = "csrf_token"
@ -25,14 +25,6 @@ func WithUser(ctx context.Context, user *model.User) context.Context {
return context.WithValue(ctx, UserKey, user)
}
func Profile(ctx context.Context) *model.Profile {
profile, _ := ctx.Value(ProfileKey).(*model.Profile)
return profile
}
func WithProfile(ctx context.Context, profile *model.Profile) context.Context {
return context.WithValue(ctx, ProfileKey, profile)
}
func URLPath(ctx context.Context) string {
path, _ := ctx.Value(URLPathKey).(string)

View file

@ -3,6 +3,7 @@
CREATE TABLE spaces (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -21,9 +22,11 @@ CREATE TABLE space_invitations (
token TEXT PRIMARY KEY NOT NULL,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
inviter_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invitee_email TEXT NOT NULL,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +goose StatementEnd

View file

@ -1,363 +0,0 @@
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.Sub(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,
Balance: decimal.Zero,
}
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,
Balance: 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.Sub(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
}
amount := amountDecimal
// 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.Sub(totalAllocated)
// Validate balance limits before creating transfer
if direction == model.TransferDirectionDeposit && amount.GreaterThan(availableBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to %s.", model.FormatMoney(availableBalance)), 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 amount.GreaterThan(acctBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to %s.", model.FormatMoney(acctBalance)), http.StatusUnprocessableEntity)
return
}
}
_, err = h.accountService.CreateTransfer(service.CreateTransferDTO{
AccountID: accountID,
Amount: amount,
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,
Balance: accountBalance,
}
// Recalculate available balance after transfer
totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID)
newAvailable := totalBalance.Sub(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,
Balance: 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.Sub(totalAllocated), true))
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Transfer deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *AccountHandler) GetTransferHistory(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get transfers", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, page, totalPages, false))
}

View file

@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/a-h/templ"
@ -228,3 +229,31 @@ func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request)
ui.Render(w, r, pages.OnboardingWelcome())
}
}
func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
user := ctxkeys.User(r.Context())
if user != nil {
spaceID, err := h.inviteService.AcceptInvite(token, user.ID)
if err != nil {
slog.Error("failed to accept invite", "error", err, "token", token)
ui.RenderError(w, r, "Failed to join space: "+err.Error(), http.StatusUnprocessableEntity)
return
}
http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther)
return
}
// Not logged in: set cookie and redirect to auth
http.SetCookie(w, &http.Cookie{
Name: "pending_invite",
Value: token,
Path: "/",
Expires: time.Now().Add(1 * time.Hour),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect)
}

View file

@ -17,13 +17,12 @@ import (
func newTestAuthHandler(dbi testutil.DBInfo) *authHandler {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, profileRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
}
@ -85,8 +84,8 @@ func TestAuthHandler_Logout(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/logout", user, profile, nil)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/logout", user, nil)
w := httptest.NewRecorder()
h.Logout(w, req)
@ -100,9 +99,9 @@ func TestAuthHandler_CompleteOnboarding_Step2(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "")
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, profile, url.Values{
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{
"step": {"2"},
"name": {"John"},
})
@ -118,9 +117,9 @@ func TestAuthHandler_CompleteOnboarding_Step3(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "")
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, profile, url.Values{
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{
"step": {"3"},
"name": {"John"},
"space_name": {"My Space"},

View file

@ -1,311 +0,0 @@
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
}
amount := amountDecimal
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: amount,
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
}
amount := amountDecimal
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: amount,
Period: model.BudgetPeriod(periodStr),
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
slog.Error("failed to update budget", "error", err)
http.Error(w, "Failed to update budget.", http.StatusInternalServerError)
return
}
// Refresh the full budgets list
tags, _ := h.tagService.GetTagsForSpace(spaceID)
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID)
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
}
func (h *BudgetHandler) DeleteBudget(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
budgetID := r.PathValue("budgetID")
if h.getBudgetForSpace(w, spaceID, budgetID) == nil {
return
}
if err := h.budgetService.DeleteBudget(budgetID); err != nil {
slog.Error("failed to delete budget", "error", err, "budget_id", budgetID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Budget deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *BudgetHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
tags, _ := h.tagService.GetTagsForSpace(spaceID)
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
if err != nil {
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
}
func (h *BudgetHandler) GetReportCharts(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
rangeKey := r.URL.Query().Get("range")
now := time.Now()
presets := service.GetPresetDateRanges(now)
var from, to time.Time
activeRange := "this_month"
if rangeKey == "custom" {
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
var err error
from, err = time.Parse("2006-01-02", fromStr)
if err != nil {
from = presets[0].From
}
to, err = time.Parse("2006-01-02", toStr)
if err != nil {
to = presets[0].To
}
activeRange = "custom"
} else {
for _, p := range presets {
if p.Key == rangeKey {
from = p.From
to = p.To
activeRange = p.Key
break
}
}
if from.IsZero() {
from = presets[0].From
to = presets[0].To
}
}
report, err := h.reportService.GetSpendingReport(spaceID, from, to)
if err != nil {
slog.Error("failed to get report charts", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ReportCharts(spaceID, report, from, to, presets, activeRange))
}

View file

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

View file

@ -1,49 +0,0 @@
package handler
import (
"log/slog"
"git.juancwu.dev/juancwu/budgit/internal/service"
)
// processTagNames normalizes tag names, deduplicates them, and resolves them
// to tag IDs. Tags that don't exist are auto-created.
func processTagNames(tagService *service.TagService, spaceID string, tagNames []string) ([]string, error) {
existingTags, err := tagService.GetTagsForSpace(spaceID)
if err != nil {
return nil, err
}
existingTagsMap := make(map[string]string)
for _, t := range existingTags {
existingTagsMap[t.Name] = t.ID
}
var finalTagIDs []string
processedTags := make(map[string]bool)
for _, rawTagName := range tagNames {
tagName := service.NormalizeTagName(rawTagName)
if tagName == "" {
continue
}
if processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
newTag, err := tagService.CreateTag(spaceID, tagName, nil)
if err != nil {
slog.Error("failed to create new tag", "error", err, "tag_name", tagName)
continue
}
finalTagIDs = append(finalTagIDs, newTag.ID)
existingTagsMap[tagName] = newTag.ID
}
processedTags[tagName] = true
}
return finalTagIDs, nil
}

View file

@ -32,8 +32,7 @@ func TestHomeHandler_HomePage_Authenticated(t *testing.T) {
h := NewHomeHandler()
user := &model.User{ID: "user-1", Email: "test@example.com"}
profile := &model.Profile{ID: "prof-1", UserID: "user-1", Name: "Test"}
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/", user, profile, nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/", user, nil)
w := httptest.NewRecorder()
h.HomePage(w, req)

View file

@ -1,353 +0,0 @@
package handler
import (
"log/slog"
"net/http"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type ListHandler struct {
spaceService *service.SpaceService
listService *service.ShoppingListService
}
func NewListHandler(ss *service.SpaceService, sls *service.ShoppingListService) *ListHandler {
return &ListHandler{
spaceService: ss,
listService: sls,
}
}
// getListForSpace fetches a shopping list and verifies it belongs to the given space.
// Returns the list on success, or writes an error response and returns nil.
func (h *ListHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList {
list, err := h.listService.GetList(listID)
if err != nil {
http.Error(w, "List not found", http.StatusNotFound)
return nil
}
if list.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return list
}
func (h *ListHandler) ListsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
cards, err := h.buildListCards(spaceID)
if err != nil {
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceListsPage(space, cards))
}
func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
err := r.ParseForm()
if err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
if name == "" {
// handle error - maybe return a toast
ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity)
return
}
newList, err := h.listService.CreateList(spaceID, name)
if err != nil {
slog.Error("failed to create list", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ListCard(spaceID, newList, nil, 1, 1))
}
func (h *ListHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
if name == "" {
ui.RenderError(w, r, "List name is required", http.StatusUnprocessableEntity)
return
}
updatedList, err := h.listService.UpdateList(listID, name)
if err != nil {
slog.Error("failed to update list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("from") == "card" {
ui.Render(w, r, shoppinglist.ListCardHeader(spaceID, updatedList))
} else {
ui.Render(w, r, shoppinglist.ListNameHeader(spaceID, updatedList))
}
}
func (h *ListHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
err := h.listService.DeleteList(listID)
if err != nil {
slog.Error("failed to delete list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("from") != "card" {
w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists")
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "List deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *ListHandler) ListPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
list := h.getListForSpace(w, spaceID, listID)
if list == nil {
return
}
items, err := h.listService.GetItemsForList(listID)
if err != nil {
slog.Error("failed to get items for list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceListDetailPage(space, list, items))
}
func (h *ListHandler) AddItemToList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
if name == "" {
ui.RenderError(w, r, "Item name cannot be empty", http.StatusUnprocessableEntity)
return
}
newItem, err := h.listService.AddItemToList(listID, name, user.ID)
if err != nil {
slog.Error("failed to add item to list", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, newItem))
}
func (h *ListHandler) ToggleItem(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
itemID := r.PathValue("itemID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
item, err := h.listService.GetItem(itemID)
if err != nil {
slog.Error("failed to get item", "error", err, "item_id", itemID)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
if item.ListID != listID {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
updatedItem, err := h.listService.UpdateItem(itemID, item.Name, !item.IsChecked)
if err != nil {
slog.Error("failed to toggle item", "error", err, "item_id", itemID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("from") == "card" {
ui.Render(w, r, shoppinglist.CardItemDetail(spaceID, updatedItem))
} else {
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem))
}
}
func (h *ListHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
itemID := r.PathValue("itemID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
item, err := h.listService.GetItem(itemID)
if err != nil {
slog.Error("failed to get item", "error", err, "item_id", itemID)
http.Error(w, "Item not found", http.StatusNotFound)
return
}
if item.ListID != listID {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
err = h.listService.DeleteItem(itemID)
if err != nil {
slog.Error("failed to delete item", "error", err, "item_id", itemID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Item deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *ListHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
items, err := h.listService.GetItemsForList(listID)
if err != nil {
slog.Error("failed to get items", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ShoppingListItems(spaceID, items))
}
func (h *ListHandler) GetLists(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
cards, err := h.buildListCards(spaceID)
if err != nil {
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ListsContainer(spaceID, cards))
}
func (h *ListHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
items, totalPages, err := h.listService.GetItemsForListPaginated(listID, page)
if err != nil {
slog.Error("failed to get paginated items", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages))
}
func (h *ListHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
return nil, err
}
cards := make([]model.ListCardData, len(lists))
for i, list := range lists {
items, totalPages, err := h.listService.GetItemsForListPaginated(list.ID, 1)
if err != nil {
return nil, err
}
cards[i] = model.ListCardData{
List: list,
Items: items,
CurrentPage: 1,
TotalPages: totalPages,
}
}
return cards, nil
}

View file

@ -1,143 +0,0 @@
package handler
import (
"log/slog"
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type MethodHandler struct {
spaceService *service.SpaceService
methodService *service.PaymentMethodService
}
func NewMethodHandler(ss *service.SpaceService, pms *service.PaymentMethodService) *MethodHandler {
return &MethodHandler{
spaceService: ss,
methodService: pms,
}
}
func (h *MethodHandler) getMethodForSpace(w http.ResponseWriter, spaceID, methodID string) *model.PaymentMethod {
method, err := h.methodService.GetMethod(methodID)
if err != nil {
http.Error(w, "Payment method not found", http.StatusNotFound)
return nil
}
if method.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return method
}
func (h *MethodHandler) PaymentMethodsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
methods, err := h.methodService.GetMethodsForSpace(spaceID)
if err != nil {
slog.Error("failed to get payment methods for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpacePaymentMethodsPage(space, methods))
}
func (h *MethodHandler) CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
methodType := model.PaymentMethodType(r.FormValue("type"))
lastFour := r.FormValue("last_four")
method, err := h.methodService.CreateMethod(service.CreatePaymentMethodDTO{
SpaceID: spaceID,
Name: name,
Type: methodType,
LastFour: lastFour,
CreatedBy: user.ID,
})
if err != nil {
slog.Error("failed to create payment method", "error", err, "space_id", spaceID)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
ui.Render(w, r, paymentmethod.MethodItem(spaceID, method))
}
func (h *MethodHandler) UpdatePaymentMethod(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
methodID := r.PathValue("methodID")
if h.getMethodForSpace(w, spaceID, methodID) == nil {
return
}
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
methodType := model.PaymentMethodType(r.FormValue("type"))
lastFour := r.FormValue("last_four")
updatedMethod, err := h.methodService.UpdateMethod(service.UpdatePaymentMethodDTO{
ID: methodID,
Name: name,
Type: methodType,
LastFour: lastFour,
})
if err != nil {
slog.Error("failed to update payment method", "error", err, "method_id", methodID)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
ui.Render(w, r, paymentmethod.MethodItem(spaceID, updatedMethod))
}
func (h *MethodHandler) DeletePaymentMethod(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
methodID := r.PathValue("methodID")
if h.getMethodForSpace(w, spaceID, methodID) == nil {
return
}
err := h.methodService.DeleteMethod(methodID)
if err != nil {
slog.Error("failed to delete payment method", "error", err, "method_id", methodID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Payment method deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}

View file

@ -1,371 +0,0 @@
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
}
amount := amountDecimal
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: amount,
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
}
amount := amountDecimal
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: amount,
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))
}

View file

@ -13,27 +13,17 @@ import (
)
type settingsHandler struct {
authService *service.AuthService
userService *service.UserService
profileService *service.ProfileService
authService *service.AuthService
userService *service.UserService
}
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) *settingsHandler {
func NewSettingsHandler(authService *service.AuthService, userService *service.UserService) *settingsHandler {
return &settingsHandler{
authService: authService,
userService: userService,
profileService: profileService,
authService: authService,
userService: userService,
}
}
func (h *settingsHandler) currentTimezone(r *http.Request) string {
profile := ctxkeys.Profile(r.Context())
if profile != nil && profile.Timezone != nil {
return *profile.Timezone
}
return "UTC"
}
func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
@ -45,7 +35,7 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", h.currentTimezone(r)))
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), ""))
}
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
@ -63,8 +53,6 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
return
}
currentTz := h.currentTimezone(r)
err = h.authService.SetPassword(user.ID, currentPassword, newPassword, confirmPassword)
if err != nil {
slog.Warn("set password failed", "error", err, "user_id", user.ID)
@ -78,12 +66,12 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
msg = "Password must be at least 12 characters"
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, currentTz))
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg))
return
}
// Password set successfully — render page with success toast
ui.Render(w, r, pages.AppSettings(true, "", currentTz))
ui.Render(w, r, pages.AppSettings(true, ""))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Password updated",
Variant: toast.VariantSuccess,
@ -92,37 +80,3 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
Duration: 5000,
}))
}
func (h *settingsHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
tz := r.FormValue("timezone")
fullUser, err := h.userService.ByID(user.ID)
if err != nil {
slog.Error("failed to fetch user for set timezone", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = h.profileService.UpdateTimezone(user.ID, tz)
if err != nil {
slog.Warn("set timezone failed", "error", err, "user_id", user.ID)
msg := "Invalid timezone selected"
if !errors.Is(err, service.ErrInvalidTimezone) {
msg = "An error occurred. Please try again."
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg, h.currentTimezone(r)))
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "", tz))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Timezone updated",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}

View file

@ -15,24 +15,22 @@ import (
func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.AuthService) {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, profileRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo)
profileSvc := service.NewProfileService(profileRepo)
return NewSettingsHandler(authSvc, userSvc, profileSvc), authSvc
return NewSettingsHandler(authSvc, userSvc), authSvc
}
func TestSettingsHandler_SettingsPage(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/app/settings", user, profile, nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/app/settings", user, nil)
w := httptest.NewRecorder()
h.SettingsPage(w, req)
@ -44,9 +42,9 @@ func TestSettingsHandler_SetPassword(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, profile, url.Values{
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, url.Values{
"new_password": {"testpassword1"},
"confirm_password": {"testpassword1"},
})
@ -61,9 +59,9 @@ func TestSettingsHandler_SetPassword_Mismatch(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, profile, url.Values{
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, url.Values{
"new_password": {"testpassword1"},
"confirm_password": {"differentpassword"},
})

View file

@ -1,228 +0,0 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"strings"
"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/pages"
)
type SpaceHandler struct {
spaceService *service.SpaceService
expenseService *service.ExpenseService
accountService *service.MoneyAccountService
reportService *service.ReportService
budgetService *service.BudgetService
recurringService *service.RecurringExpenseService
listService *service.ShoppingListService
tagService *service.TagService
methodService *service.PaymentMethodService
loanService *service.LoanService
receiptService *service.ReceiptService
recurringReceiptService *service.RecurringReceiptService
}
func NewSpaceHandler(
ss *service.SpaceService,
es *service.ExpenseService,
mas *service.MoneyAccountService,
rps *service.ReportService,
bs *service.BudgetService,
rs *service.RecurringExpenseService,
sls *service.ShoppingListService,
ts *service.TagService,
pms *service.PaymentMethodService,
ls *service.LoanService,
rcs *service.ReceiptService,
rrs *service.RecurringReceiptService,
) *SpaceHandler {
return &SpaceHandler{
spaceService: ss,
expenseService: es,
accountService: mas,
reportService: rps,
budgetService: bs,
recurringService: rs,
listService: sls,
tagService: ts,
methodService: pms,
loanService: ls,
receiptService: rcs,
recurringReceiptService: rrs,
}
}
func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
spaces, err := h.spaceService.GetSpacesForUser(user.ID)
if err != nil {
slog.Error("failed to get spaces for user", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.Dashboard(spaces))
}
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, `<p id="create-space-error" hx-swap-oob="true" class="text-sm text-destructive">Space name is required</p>`)
return
}
space, err := h.spaceService.CreateSpace(name, user.ID)
if err != nil {
slog.Error("failed to create space", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID)
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
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
}
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
}
allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
allocated = decimal.Zero
}
balance = balance.Sub(allocated)
// This month's report
now := time.Now()
presets := service.GetPresetDateRanges(now)
report, err := h.reportService.GetSpendingReport(spaceID, presets[0].From, presets[0].To)
if err != nil {
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
report = nil
}
// Budgets
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
if err != nil {
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
}
// Recurring expenses
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
if err != nil {
slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID)
}
// Shopping lists
cards, err := h.buildListCards(spaceID)
if err != nil {
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
}
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.SpaceOverviewPage(pages.OverviewData{
Space: space,
Balance: balance,
Allocated: allocated,
Report: report,
Budgets: budgets,
UpcomingRecurring: recs,
ShoppingLists: cards,
Tags: tags,
Methods: methods,
ListsWithItems: listsWithItems,
}))
}
func (h *SpaceHandler) ReportsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
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
}
now := time.Now()
presets := service.GetPresetDateRanges(now)
from := presets[0].From
to := presets[0].To
report, err := h.reportService.GetSpendingReport(spaceID, from, to)
if err != nil {
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceReportsPage(space, report, presets, "this_month"))
}
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
return nil, err
}
cards := make([]model.ListCardData, len(lists))
for i, list := range lists {
items, totalPages, err := h.listService.GetItemsForListPaginated(list.ID, 1)
if err != nil {
return nil, err
}
cards[i] = model.ListCardData{
List: list,
Items: items,
CurrentPage: 1,
TotalPages: totalPages,
}
}
return cards, nil
}

View file

@ -1,578 +0,0 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"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/pages"
)
func (h *SpaceHandler) LoansPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to get space", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
loans, totalPages, err := h.loanService.GetLoansWithSummaryForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get loans", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceLoansPage(space, loans, page, totalPages))
}
func (h *SpaceHandler) CreateLoan(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
interestStr := r.FormValue("interest_rate")
var interestBps int
if interestStr != "" {
interestRate, err := decimal.NewFromString(interestStr)
if err == nil {
interestBps = int(interestRate.Mul(decimal.NewFromInt(100)).IntPart())
}
}
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
dto := service.CreateLoanDTO{
SpaceID: spaceID,
UserID: user.ID,
Name: name,
Description: description,
OriginalAmount: amount,
InterestRateBps: interestBps,
StartDate: startDate,
EndDate: endDate,
}
_, err = h.loanService.CreateLoan(dto)
if err != nil {
slog.Error("failed to create loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Return updated loans list
loans, totalPages, err := h.loanService.GetLoansWithSummaryForSpacePaginated(spaceID, 1)
if err != nil {
slog.Error("failed to get loans after create", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.LoansListContent(spaceID, loans, 1, totalPages))
}
func (h *SpaceHandler) LoanDetailPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to get space", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
loan, err := h.loanService.GetLoanWithSummary(loanID)
if err != nil {
slog.Error("failed to get loan", "error", err)
http.Error(w, "Not Found", http.StatusNotFound)
return
}
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
receipts, totalPages, err := h.receiptService.GetReceiptsForLoanPaginated(loanID, page)
if err != nil {
slog.Error("failed to get receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
recurringReceipts, err := h.recurringReceiptService.GetRecurringReceiptsWithSourcesForLoan(loanID)
if err != nil {
slog.Error("failed to get recurring receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
accounts, err := h.accountService.GetAccountsForSpace(spaceID)
if err != nil {
slog.Error("failed to get accounts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err)
balance = decimal.Zero
}
ui.Render(w, r, pages.SpaceLoanDetailPage(space, loan, receipts, page, totalPages, recurringReceipts, accounts, balance))
}
func (h *SpaceHandler) UpdateLoan(w http.ResponseWriter, r *http.Request) {
loanID := r.PathValue("loanID")
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
interestStr := r.FormValue("interest_rate")
var interestBps int
if interestStr != "" {
interestRate, err := decimal.NewFromString(interestStr)
if err == nil {
interestBps = int(interestRate.Mul(decimal.NewFromInt(100)).IntPart())
}
}
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
dto := service.UpdateLoanDTO{
ID: loanID,
Name: name,
Description: description,
OriginalAmount: amount,
InterestRateBps: interestBps,
StartDate: startDate,
EndDate: endDate,
}
_, err = h.loanService.UpdateLoan(dto)
if err != nil {
slog.Error("failed to update loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Redirect to loan detail
spaceID := r.PathValue("spaceID")
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteLoan(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
if err := h.loanService.DeleteLoan(loanID); err != nil {
slog.Error("failed to delete loan", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans", spaceID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) CreateReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
date = time.Now()
}
// Parse funding sources from parallel arrays
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.CreateReceiptDTO{
LoanID: loanID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: amount,
Date: date,
FundingSources: fundingSources,
}
_, err = h.receiptService.CreateReceipt(dto)
if err != nil {
slog.Error("failed to create receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
// Return updated loan detail
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) UpdateReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
receiptID := r.PathValue("receiptID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dateStr := r.FormValue("date")
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
date = time.Now()
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.UpdateReceiptDTO{
ID: receiptID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: amount,
Date: date,
FundingSources: fundingSources,
}
_, err = h.receiptService.UpdateReceipt(dto)
if err != nil {
slog.Error("failed to update receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
receiptID := r.PathValue("receiptID")
if err := h.receiptService.DeleteReceipt(receiptID, spaceID); err != nil {
slog.Error("failed to delete receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) GetReceiptsList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
receipts, totalPages, err := h.receiptService.GetReceiptsForLoanPaginated(loanID, page)
if err != nil {
slog.Error("failed to get receipts", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ReceiptsListContent(spaceID, loanID, receipts, page, totalPages))
}
func (h *SpaceHandler) CreateRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
user := ctxkeys.User(r.Context())
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.CreateRecurringReceiptDTO{
LoanID: loanID,
SpaceID: spaceID,
UserID: user.ID,
Description: description,
TotalAmount: amount,
Frequency: frequency,
StartDate: startDate,
EndDate: endDate,
FundingSources: fundingSources,
}
_, err = h.recurringReceiptService.CreateRecurringReceipt(dto)
if err != nil {
slog.Error("failed to create recurring receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) UpdateRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
description := strings.TrimSpace(r.FormValue("description"))
amountStr := r.FormValue("amount")
amount, err := decimal.NewFromString(amountStr)
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
frequency := model.Frequency(r.FormValue("frequency"))
startDateStr := r.FormValue("start_date")
startDate, err := time.Parse("2006-01-02", startDateStr)
if err != nil {
startDate = time.Now()
}
var endDate *time.Time
endDateStr := r.FormValue("end_date")
if endDateStr != "" {
parsed, err := time.Parse("2006-01-02", endDateStr)
if err == nil {
endDate = &parsed
}
}
fundingSources, err := parseFundingSources(r)
if err != nil {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
dto := service.UpdateRecurringReceiptDTO{
ID: recurringReceiptID,
Description: description,
TotalAmount: amount,
Frequency: frequency,
StartDate: startDate,
EndDate: endDate,
FundingSources: fundingSources,
}
_, err = h.recurringReceiptService.UpdateRecurringReceipt(dto)
if err != nil {
slog.Error("failed to update recurring receipt", "error", err)
ui.RenderError(w, r, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) DeleteRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
if err := h.recurringReceiptService.DeleteRecurringReceipt(recurringReceiptID); err != nil {
slog.Error("failed to delete recurring receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
func (h *SpaceHandler) ToggleRecurringReceipt(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
loanID := r.PathValue("loanID")
recurringReceiptID := r.PathValue("recurringReceiptID")
_, err := h.recurringReceiptService.ToggleRecurringReceipt(recurringReceiptID)
if err != nil {
slog.Error("failed to toggle recurring receipt", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loanID))
w.WriteHeader(http.StatusOK)
}
// parseFundingSources parses funding sources from parallel form arrays:
// source_type[], source_amount[], source_account_id[]
func parseFundingSources(r *http.Request) ([]service.FundingSourceDTO, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
sourceTypes := r.Form["source_type"]
sourceAmounts := r.Form["source_amount"]
sourceAccountIDs := r.Form["source_account_id"]
if len(sourceTypes) == 0 {
return nil, fmt.Errorf("no funding sources provided")
}
if len(sourceTypes) != len(sourceAmounts) {
return nil, fmt.Errorf("mismatched funding source fields")
}
var sources []service.FundingSourceDTO
for i, srcType := range sourceTypes {
amount, err := decimal.NewFromString(sourceAmounts[i])
if err != nil || amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("invalid funding source amount")
}
src := service.FundingSourceDTO{
SourceType: model.FundingSourceType(srcType),
Amount: amount,
}
if srcType == string(model.FundingSourceAccount) {
if i < len(sourceAccountIDs) && sourceAccountIDs[i] != "" {
src.AccountID = sourceAccountIDs[i]
} else {
return nil, fmt.Errorf("account source requires account_id")
}
}
sources = append(sources, src)
}
return sources, nil
}

View file

@ -1,336 +0,0 @@
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) DeleteSpace(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
}
confirmationName := r.FormValue("confirmation_name")
if confirmationName != space.Name {
ui.RenderError(w, r, "Space name does not match", http.StatusUnprocessableEntity)
return
}
if err := h.spaceService.DeleteSpace(spaceID); err != nil {
slog.Error("failed to delete space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/app/spaces")
w.WriteHeader(http.StatusOK)
}
func (h *SpaceSettingsHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
user := ctxkeys.User(r.Context())
if user != nil {
spaceID, err := h.inviteService.AcceptInvite(token, user.ID)
if err != nil {
slog.Error("failed to accept invite", "error", err, "token", token)
ui.RenderError(w, r, "Failed to join space: "+err.Error(), http.StatusUnprocessableEntity)
return
}
http.Redirect(w, r, "/app/spaces/"+spaceID, http.StatusSeeOther)
return
}
// Not logged in: set cookie and redirect to auth
http.SetCookie(w, &http.Cookie{
Name: "pending_invite",
Value: token,
Path: "/",
Expires: time.Now().Add(1 * time.Hour),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect)
}

View file

@ -1,217 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
// 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)
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB)
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
recurringRepo := repository.NewRecurringExpenseRepository(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 &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 TestListHandler_CreateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists", user, profile, url.Values{"name": {"Groceries"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestListHandler_CreateList_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists", user, profile, url.Values{"name": {""}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateList(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
})
}
func TestListHandler_DeleteList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists/"+list.ID+"?from=card", user, profile, nil)
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("listID", list.ID)
w := httptest.NewRecorder()
h.DeleteList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestListHandler_AddItemToList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists/"+list.ID+"/items", user, profile, url.Values{"name": {"Milk"}})
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("listID", list.ID)
w := httptest.NewRecorder()
h.AddItemToList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestTagHandler_CreateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/tags", user, profile, url.Values{"name": {"food"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateTag(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestTagHandler_DeleteTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/tags/"+tag.ID, user, profile, nil)
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("tagID", tag.ID)
w := httptest.NewRecorder()
h.DeleteTag(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAccountHandler_CreateAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/accounts", user, profile, url.Values{"name": {"Savings"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateAccount(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestMethodHandler_CreatePaymentMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
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")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/payment-methods", user, profile, url.Values{
"name": {"Visa"},
"type": {"credit"},
"last_four": {"4242"},
})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreatePaymentMethod(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}

View file

@ -1,107 +0,0 @@
package handler
import (
"log/slog"
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type TagHandler struct {
spaceService *service.SpaceService
tagService *service.TagService
}
func NewTagHandler(ss *service.SpaceService, ts *service.TagService) *TagHandler {
return &TagHandler{
spaceService: ss,
tagService: ts,
}
}
// getTagForSpace fetches a tag and verifies it belongs to the given space.
func (h *TagHandler) getTagForSpace(w http.ResponseWriter, spaceID, tagID string) *model.Tag {
t, err := h.tagService.GetTagByID(tagID)
if err != nil {
http.Error(w, "Tag not found", http.StatusNotFound)
return nil
}
if t.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return t
}
func (h *TagHandler) TagsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
tags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
slog.Error("failed to get tags for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceTagsPage(space, tags))
}
func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
color := r.FormValue("color") // color is optional
var colorPtr *string
if color != "" {
colorPtr = &color
}
newTag, err := h.tagService.CreateTag(spaceID, name, colorPtr)
if err != nil {
slog.Error("failed to create tag", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, tag.Tag(newTag))
}
func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
tagID := r.PathValue("tagID")
if h.getTagForSpace(w, spaceID, tagID) == nil {
return
}
err := h.tagService.DeleteTag(tagID)
if err != nil {
slog.Error("failed to delete tag", "error", err, "tag_id", tagID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Tag deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}

View file

@ -7,8 +7,8 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/service"
)
// AuthMiddleware checks for JWT token and adds user + profile + subscription to context if valid
func AuthMiddleware(authService *service.AuthService, userService *service.UserService, profileService *service.ProfileService) func(http.Handler) http.Handler {
// AuthMiddleware checks for JWT token and adds user to context if valid
func AuthMiddleware(authService *service.AuthService, userService *service.UserService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get JWT from cookie
@ -39,7 +39,6 @@ func AuthMiddleware(authService *service.AuthService, userService *service.UserS
// Fetch user from database
user, err := userService.ByID(userID)
if err != nil {
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
@ -48,17 +47,8 @@ func AuthMiddleware(authService *service.AuthService, userService *service.UserS
// Security: Remove password hash from context
user.PasswordHash = nil
profile, err := profileService.ByUserID(userID)
if err != nil {
// Profile not found - this shouldn't happen but handle gracefully
authService.ClearJWTCookie(w)
next.ServeHTTP(w, r)
return
}
// Add user + profile to context
// Add user to context
ctx := ctxkeys.WithUser(r.Context(), user)
ctx = ctxkeys.WithProfile(ctx, profile)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@ -85,10 +75,8 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return
}
// Check if user has completed onboarding
// Uses profile.Name as indicator (empty = incomplete onboarding)
profile := ctxkeys.Profile(r.Context())
if profile.Name == "" && r.URL.Path != "/auth/onboarding" {
// Check if user has completed onboarding (name set)
if (user.Name == nil || *user.Name == "") && r.URL.Path != "/auth/onboarding" {
redirect(w, r, "/auth/onboarding", http.StatusSeeOther)
return
}

View file

@ -12,6 +12,7 @@ const (
type Space struct {
ID string `db:"id"`
Name string `db:"name"`
OwnerID string `db:"owner_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@ -23,11 +24,30 @@ type SpaceMember struct {
JoinedAt time.Time `db:"joined_at"`
}
type SpaceInvitation struct {
Token string `db:"token"`
SpaceID string `db:"space_id"`
InviterID string `db:"inviter_id"`
InviteeEmail string `db:"invitee_email"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
type SpaceMemberWithProfile struct {
SpaceID string `db:"space_id"`
UserID string `db:"user_id"`
Role Role `db:"role"`
JoinedAt time.Time `db:"joined_at"`
Name *string `db:"name"`
Email string `db:"email"`
}
type InvitationStatus string
const (
InvitationStatusPending InvitationStatus = "pending"
InvitationStatusAccepted InvitationStatus = "accepted"
InvitationStatusExpired InvitationStatus = "expired"
)
type SpaceInvitation struct {
Token string `db:"token"`
SpaceID string `db:"space_id"`
InviterID string `db:"inviter_id"`
Email string `db:"email"`
Status InvitationStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}

View file

@ -1,168 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrBudgetNotFound = errors.New("budget not found")
)
type BudgetRepository interface {
Create(budget *model.Budget, tagIDs []string) error
GetByID(id string) (*model.Budget, error)
GetBySpaceID(spaceID string) ([]*model.Budget, error)
GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error)
GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error)
Update(budget *model.Budget, tagIDs []string) error
Delete(id string) error
}
type budgetRepository struct {
db *sqlx.DB
}
func NewBudgetRepository(db *sqlx.DB) BudgetRepository {
return &budgetRepository{db: db}
}
func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `INSERT INTO budgets (id, space_id, amount, period, start_date, end_date, is_active, created_by, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`
if _, err := tx.Exec(query, budget.ID, budget.SpaceID, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
budget := &model.Budget{}
err := r.db.Get(budget, `SELECT * FROM budgets WHERE id = $1;`, id)
if err == sql.ErrNoRows {
return nil, ErrBudgetNotFound
}
return budget, err
}
func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error) {
var budgets []*model.Budget
err := r.db.Select(&budgets, `SELECT * FROM budgets WHERE space_id = $1 ORDER BY created_at DESC;`, spaceID)
return budgets, err
}
func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (decimal.Decimal, error) {
if len(tagIDs) == 0 {
return decimal.Zero, nil
}
query, args, err := sqlx.In(`
SELECT COALESCE(SUM(CAST(e.amount AS DECIMAL)), 0)
FROM expenses e
WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ?
AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?))
`, spaceID, periodStart, periodEnd, tagIDs)
if err != nil {
return decimal.Zero, err
}
query = r.db.Rebind(query)
var spent decimal.Decimal
err = r.db.Get(&spent, query, args...)
return spent, err
}
func (r *budgetRepository) GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error) {
if len(budgetIDs) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
BudgetID string `db:"budget_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
}
query, args, err := sqlx.In(`
SELECT bt.budget_id, t.id, t.space_id, t.name, t.color
FROM budget_tags bt
JOIN tags t ON bt.tag_id = t.id
WHERE bt.budget_id IN (?)
ORDER BY t.name;
`, budgetIDs)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]*model.Tag)
for _, rw := range rows {
result[rw.BudgetID] = append(result[rw.BudgetID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE budgets SET amount = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;`
if _, err := tx.Exec(query, budget.Amount, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM budget_tags WHERE budget_id = $1;`, budget.ID); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *budgetRepository) Delete(id string) error {
result, err := r.db.Exec(`DELETE FROM budgets WHERE id = $1;`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrBudgetNotFound
}
return err
}

View file

@ -1,347 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrExpenseNotFound = errors.New("expense not found")
)
type ExpenseRepository interface {
Create(expense *model.Expense, tagIDs []string, itemIDs []string) error
GetByID(id string) (*model.Expense, error)
GetBySpaceID(spaceID string) ([]*model.Expense, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error)
CountBySpaceID(spaceID string) (int, error)
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error)
Update(expense *model.Expense, tagIDs []string) error
Delete(id string) error
// Report queries
GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error)
GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error)
GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error)
GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error)
GetExpensesByPaymentMethod(spaceID string, from, to time.Time) ([]*model.PaymentMethodExpenseSummary, error)
}
type expenseRepository struct {
db *sqlx.DB
}
func NewExpenseRepository(db *sqlx.DB) ExpenseRepository {
return &expenseRepository{db: db}
}
func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`
if _, err := tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.RecurringExpenseID, expense.CreatedAt, expense.UpdatedAt); err != nil {
return err
}
if len(tagIDs) > 0 {
queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(queryTags, expense.ID, tagID); err != nil {
return err
}
}
}
if len(itemIDs) > 0 {
queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);`
for _, itemID := range itemIDs {
if _, err := tx.Exec(queryItems, expense.ID, itemID); err != nil {
return err
}
}
}
return nil
})
}
func (r *expenseRepository) GetByID(id string) (*model.Expense, error) {
expense := &model.Expense{}
query := `SELECT * FROM expenses WHERE id = $1;`
err := r.db.Get(expense, query, id)
if err == sql.ErrNoRows {
return nil, ErrExpenseNotFound
}
return expense, err
}
func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, error) {
var expenses []*model.Expense
query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC;`
err := r.db.Select(&expenses, query, spaceID)
if err != nil {
return nil, err
}
return expenses, nil
}
func (r *expenseRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) {
var expenses []*model.Expense
query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&expenses, query, spaceID, limit, offset)
if err != nil {
return nil, err
}
return expenses, nil
}
func (r *expenseRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM expenses WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
var summaries []*model.TagExpenseSummary
query := `
SELECT
t.id as tag_id,
t.name as tag_name,
t.color as tag_color,
SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id
JOIN tags t ON et.tag_id = t.id
WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3
GROUP BY t.id, t.name, t.color
ORDER BY total_amount DESC;
`
err := r.db.Select(&summaries, query, spaceID, fromDate, toDate)
if err != nil {
return nil, err
}
return summaries, nil
}
func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
if len(expenseIDs) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
ExpenseID string `db:"expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
}
query, args, err := sqlx.In(`
SELECT et.expense_id, t.id, t.space_id, t.name, t.color
FROM expense_tags et
JOIN tags t ON et.tag_id = t.id
WHERE et.expense_id IN (?)
ORDER BY t.name;
`, expenseIDs)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]*model.Tag)
for _, rw := range rows {
result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *expenseRepository) GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error) {
if len(expenseIDs) == 0 {
return make(map[string]*model.PaymentMethod), nil
}
type row struct {
ExpenseID string `db:"expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Type model.PaymentMethodType `db:"type"`
LastFour *string `db:"last_four"`
}
query, args, err := sqlx.In(`
SELECT e.id AS expense_id, pm.id, pm.space_id, pm.name, pm.type, pm.last_four
FROM expenses e
JOIN payment_methods pm ON e.payment_method_id = pm.id
WHERE e.id IN (?) AND e.payment_method_id IS NOT NULL;
`, expenseIDs)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string]*model.PaymentMethod)
for _, rw := range rows {
result[rw.ExpenseID] = &model.PaymentMethod{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Type: rw.Type,
LastFour: rw.LastFour,
}
}
return result, nil
}
func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE expenses SET description = $1, amount = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;`
if _, err := tx.Exec(query, expense.Description, expense.Amount, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID); err != nil {
return err
}
if len(tagIDs) > 0 {
insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *expenseRepository) Delete(id string) error {
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
}
func (r *expenseRepository) GetDailySpending(spaceID string, from, to time.Time) ([]*model.DailySpending, error) {
var results []*model.DailySpending
query := `
SELECT date, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY date
ORDER BY date ASC;
`
err := r.db.Select(&results, query, spaceID, from, to)
return results, err
}
func (r *expenseRepository) GetMonthlySpending(spaceID string, from, to time.Time) ([]*model.MonthlySpending, error) {
var results []*model.MonthlySpending
var query string
if r.db.DriverName() == "sqlite" {
query = `
SELECT strftime('%Y-%m', date) as month, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY strftime('%Y-%m', date)
ORDER BY month ASC;`
} else {
query = `
SELECT TO_CHAR(date, 'YYYY-MM') as month, SUM(CAST(amount AS DECIMAL)) as total
FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
GROUP BY TO_CHAR(date, 'YYYY-MM')
ORDER BY month ASC;`
}
err := r.db.Select(&results, query, spaceID, from, to)
return results, err
}
func (r *expenseRepository) GetTopExpenses(spaceID string, from, to time.Time, limit int) ([]*model.Expense, error) {
var results []*model.Expense
query := `
SELECT * FROM expenses
WHERE space_id = $1 AND type = 'expense' AND date >= $2 AND date <= $3
ORDER BY CAST(amount AS DECIMAL) DESC
LIMIT $4;
`
err := r.db.Select(&results, query, spaceID, from, to, limit)
return results, err
}
func (r *expenseRepository) GetIncomeVsExpenseSummary(spaceID string, from, to time.Time) (decimal.Decimal, decimal.Decimal, error) {
type summary struct {
Type string `db:"type"`
Total decimal.Decimal `db:"total"`
}
var results []summary
query := `
SELECT type, COALESCE(SUM(CAST(amount AS DECIMAL)), 0) as total
FROM expenses
WHERE space_id = $1 AND date >= $2 AND date <= $3
GROUP BY type;
`
err := r.db.Select(&results, query, spaceID, from, to)
if err != nil {
return decimal.Zero, decimal.Zero, err
}
income := decimal.Zero
expenseTotal := decimal.Zero
for _, r := range results {
if r.Type == "topup" {
income = r.Total
} else if r.Type == "expense" {
expenseTotal = r.Total
}
}
return income, expenseTotal, nil
}
func (r *expenseRepository) GetExpensesByPaymentMethod(spaceID string, from, to time.Time) ([]*model.PaymentMethodExpenseSummary, error) {
var summaries []*model.PaymentMethodExpenseSummary
query := `
SELECT COALESCE(pm.id, 'cash') as payment_method_id,
COALESCE(pm.name, 'Cash') as payment_method_name,
COALESCE(pm.type, 'cash') as payment_method_type,
SUM(CAST(e.amount AS DECIMAL)) as total_amount
FROM expenses e
LEFT JOIN payment_methods pm ON e.payment_method_id = pm.id
WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3
GROUP BY pm.id, pm.name, pm.type
ORDER BY total_amount DESC;
`
err := r.db.Select(&summaries, query, spaceID, from, to)
if err != nil {
return nil, err
}
return summaries, nil
}

View file

@ -1,246 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, expense.ID, fetched.ID)
assert.Equal(t, "Lunch", fetched.Description)
assert.True(t, decimal.RequireFromString("15.49").Equal(fetched.Amount))
assert.Equal(t, model.ExpenseTypeExpense, fetched.Type)
})
}
func TestExpenseRepository_GetBySpaceIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.75"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.50"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 3", decimal.RequireFromString("30.25"), model.ExpenseTypeExpense)
expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, expenses, 2)
})
}
func TestExpenseRepository_CountBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", decimal.RequireFromString("10.75"), model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", decimal.RequireFromString("20.50"), model.ExpenseTypeExpense)
count, err := repo.CountBySpaceID(space.ID)
require.NoError(t, err)
assert.Equal(t, 2, count)
})
}
func TestExpenseRepository_GetTagsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Groceries", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Weekly groceries",
Amount: decimal.RequireFromString("49.99"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, tagMap, expense.ID)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag.ID, tagMap[expense.ID][0].ID)
assert.Equal(t, "Groceries", tagMap[expense.ID][0].Name)
})
}
func TestExpenseRepository_GetPaymentMethodsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Online purchase",
Amount: decimal.RequireFromString("29.95"),
Type: model.ExpenseTypeExpense,
Date: now,
PaymentMethodID: &method.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, nil, nil)
require.NoError(t, err)
methodMap, err := repo.GetPaymentMethodsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, methodMap, expense.ID)
assert.Equal(t, method.ID, methodMap[expense.ID].ID)
assert.Equal(t, "Visa", methodMap[expense.ID].Name)
assert.Equal(t, model.PaymentMethodTypeCredit, methodMap[expense.ID].Type)
})
}
func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
color := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", &color)
now := time.Now()
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
expense1 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense1, []string{tag.ID}, nil)
require.NoError(t, err)
expense2 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Dinner",
Amount: decimal.RequireFromString("24.52"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err = repo.Create(expense2, []string{tag.ID}, nil)
require.NoError(t, err)
summaries, err := repo.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.Equal(t, "Food", summaries[0].TagName)
assert.True(t, decimal.RequireFromString("40.01").Equal(summaries[0].TotalAmount))
})
}
func TestExpenseRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag1 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag A", nil)
tag2 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag B", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Original",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag1.ID}, nil)
require.NoError(t, err)
expense.Description = "Updated"
expense.UpdatedAt = time.Now()
err = repo.Update(expense, []string{tag2.ID})
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Description)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag2.ID, tagMap[expense.ID][0].ID)
})
}
func TestExpenseRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
expense := testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "To Delete", decimal.RequireFromString("4.99"), model.ExpenseTypeExpense)
err := repo.Delete(expense.ID)
require.NoError(t, err)
_, err = repo.GetByID(expense.ID)
assert.ErrorIs(t, err, ErrExpenseNotFound)
})
}

View file

@ -1,109 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrListItemNotFound = errors.New("list item not found")
)
type ListItemRepository interface {
Create(item *model.ListItem) error
GetByID(id string) (*model.ListItem, error)
GetByListID(listID string) ([]*model.ListItem, error)
GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error)
CountByListID(listID string) (int, error)
Update(item *model.ListItem) error
Delete(id string) error
DeleteByListID(listID string) error
}
type listItemRepository struct {
db *sqlx.DB
}
func NewListItemRepository(db *sqlx.DB) ListItemRepository {
return &listItemRepository{db: db}
}
func (r *listItemRepository) Create(item *model.ListItem) error {
query := `INSERT INTO list_items (id, list_id, name, is_checked, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
_, err := r.db.Exec(query, item.ID, item.ListID, item.Name, item.IsChecked, item.CreatedBy, item.CreatedAt, item.UpdatedAt)
return err
}
func (r *listItemRepository) GetByID(id string) (*model.ListItem, error) {
item := &model.ListItem{}
query := `SELECT * FROM list_items WHERE id = $1;`
err := r.db.Get(item, query, id)
if err == sql.ErrNoRows {
return nil, ErrListItemNotFound
}
return item, err
}
func (r *listItemRepository) GetByListID(listID string) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at ASC;`
err := r.db.Select(&items, query, listID)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&items, query, listID, limit, offset)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) CountByListID(listID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM list_items WHERE list_id = $1;`
err := r.db.Get(&count, query, listID)
return count, err
}
func (r *listItemRepository) Update(item *model.ListItem) error {
item.UpdatedAt = time.Now()
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, item.Name, item.IsChecked, item.UpdatedAt, item.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) Delete(id string) error {
query := `DELETE FROM list_items WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrListItemNotFound
}
return err
}
func (r *listItemRepository) DeleteByListID(listID string) error {
query := `DELETE FROM list_items WHERE list_id = $1;`
_, err := r.db.Exec(query, listID)
return err
}

View file

@ -1,161 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListItemRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: list.ID,
Name: "Apples",
IsChecked: false,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, item.ID, fetched.ID)
assert.Equal(t, list.ID, fetched.ListID)
assert.Equal(t, "Apples", fetched.Name)
assert.False(t, fetched.IsChecked)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestListItemRepository_GetByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item1 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
item2 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
require.Len(t, items, 2)
// Ordered by created_at ASC, so item1 should be first.
assert.Equal(t, item1.ID, items[0].ID)
assert.Equal(t, item2.ID, items[1].ID)
})
}
func TestListItemRepository_GetByListIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
items, err := repo.GetByListIDPaginated(list.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, items, 2)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_CountByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Original", user.ID)
item.Name = "Updated"
item.IsChecked = true
err := repo.Update(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Name)
assert.True(t, fetched.IsChecked)
})
}
func TestListItemRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "To Delete", user.ID)
err := repo.Delete(item.ID)
require.NoError(t, err)
_, err = repo.GetByID(item.ID)
assert.ErrorIs(t, err, ErrListItemNotFound)
})
}
func TestListItemRepository_DeleteByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
err := repo.DeleteByListID(list.ID)
require.NoError(t, err)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}

View file

@ -1,108 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrLoanNotFound = errors.New("loan not found")
)
type LoanRepository interface {
Create(loan *model.Loan) error
GetByID(id string) (*model.Loan, error)
GetBySpaceID(spaceID string) ([]*model.Loan, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error)
CountBySpaceID(spaceID string) (int, error)
Update(loan *model.Loan) error
Delete(id string) error
SetPaidOff(id string, paidOff bool) error
GetTotalPaidForLoan(loanID string) (decimal.Decimal, error)
GetReceiptCountForLoan(loanID string) (int, error)
}
type loanRepository struct {
db *sqlx.DB
}
func NewLoanRepository(db *sqlx.DB) LoanRepository {
return &loanRepository{db: db}
}
func (r *loanRepository) Create(loan *model.Loan) error {
query := `INSERT INTO loans (id, space_id, name, description, original_amount, interest_rate_bps, start_date, end_date, is_paid_off, created_by, created_at, updated_at, original_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 0);`
_, err := r.db.Exec(query, loan.ID, loan.SpaceID, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.IsPaidOff, loan.CreatedBy, loan.CreatedAt, loan.UpdatedAt)
return err
}
func (r *loanRepository) GetByID(id string) (*model.Loan, error) {
loan := &model.Loan{}
query := `SELECT * FROM loans WHERE id = $1;`
err := r.db.Get(loan, query, id)
if err == sql.ErrNoRows {
return nil, ErrLoanNotFound
}
return loan, err
}
func (r *loanRepository) GetBySpaceID(spaceID string) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC;`
err := r.db.Select(&loans, query, spaceID)
return loans, err
}
func (r *loanRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Loan, error) {
var loans []*model.Loan
query := `SELECT * FROM loans WHERE space_id = $1 ORDER BY is_paid_off ASC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&loans, query, spaceID, limit, offset)
return loans, err
}
func (r *loanRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM loans WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *loanRepository) Update(loan *model.Loan) error {
query := `UPDATE loans SET name = $1, description = $2, original_amount = $3, interest_rate_bps = $4, start_date = $5, end_date = $6, updated_at = $7 WHERE id = $8;`
result, err := r.db.Exec(query, loan.Name, loan.Description, loan.OriginalAmount, loan.InterestRateBps, loan.StartDate, loan.EndDate, loan.UpdatedAt, loan.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrLoanNotFound
}
return err
}
func (r *loanRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM loans WHERE id = $1;`, id)
return err
}
func (r *loanRepository) SetPaidOff(id string, paidOff bool) error {
_, err := r.db.Exec(`UPDATE loans SET is_paid_off = $1, updated_at = $2 WHERE id = $3;`, paidOff, time.Now(), id)
return err
}
func (r *loanRepository) GetTotalPaidForLoan(loanID string) (decimal.Decimal, error) {
var total decimal.Decimal
err := r.db.Get(&total, `SELECT COALESCE(SUM(CAST(total_amount AS DECIMAL)), 0) FROM receipts WHERE loan_id = $1;`, loanID)
return total, err
}
func (r *loanRepository) GetReceiptCountForLoan(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}

View file

@ -1,166 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrMoneyAccountNotFound = errors.New("money account not found")
ErrTransferNotFound = errors.New("account transfer not found")
)
type MoneyAccountRepository interface {
Create(account *model.MoneyAccount) error
GetByID(id string) (*model.MoneyAccount, error)
GetBySpaceID(spaceID string) ([]*model.MoneyAccount, error)
Update(account *model.MoneyAccount) error
Delete(id string) error
CreateTransfer(transfer *model.AccountTransfer) error
GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error)
DeleteTransfer(id string) error
GetAccountBalance(accountID string) (decimal.Decimal, error)
GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error)
GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error)
CountTransfersBySpaceID(spaceID string) (int, error)
}
type moneyAccountRepository struct {
db *sqlx.DB
}
func NewMoneyAccountRepository(db *sqlx.DB) MoneyAccountRepository {
return &moneyAccountRepository{db: db}
}
func (r *moneyAccountRepository) Create(account *model.MoneyAccount) error {
query := `INSERT INTO money_accounts (id, space_id, name, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, account.ID, account.SpaceID, account.Name, account.CreatedBy, account.CreatedAt, account.UpdatedAt)
return err
}
func (r *moneyAccountRepository) GetByID(id string) (*model.MoneyAccount, error) {
account := &model.MoneyAccount{}
query := `SELECT * FROM money_accounts WHERE id = $1;`
err := r.db.Get(account, query, id)
if err == sql.ErrNoRows {
return nil, ErrMoneyAccountNotFound
}
return account, err
}
func (r *moneyAccountRepository) GetBySpaceID(spaceID string) ([]*model.MoneyAccount, error) {
var accounts []*model.MoneyAccount
query := `SELECT * FROM money_accounts WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&accounts, query, spaceID)
if err != nil {
return nil, err
}
return accounts, nil
}
func (r *moneyAccountRepository) Update(account *model.MoneyAccount) error {
account.UpdatedAt = time.Now()
query := `UPDATE money_accounts SET name = $1, updated_at = $2 WHERE id = $3;`
result, err := r.db.Exec(query, account.Name, account.UpdatedAt, account.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrMoneyAccountNotFound
}
return err
}
func (r *moneyAccountRepository) Delete(id string) error {
query := `DELETE FROM money_accounts WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrMoneyAccountNotFound
}
return err
}
func (r *moneyAccountRepository) CreateTransfer(transfer *model.AccountTransfer) error {
query := `INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`
_, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt)
return err
}
func (r *moneyAccountRepository) GetTransfersByAccountID(accountID string) ([]*model.AccountTransfer, error) {
var transfers []*model.AccountTransfer
query := `SELECT * FROM account_transfers WHERE account_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&transfers, query, accountID)
if err != nil {
return nil, err
}
return transfers, nil
}
func (r *moneyAccountRepository) DeleteTransfer(id string) error {
query := `DELETE FROM account_transfers WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTransferNotFound
}
return err
}
func (r *moneyAccountRepository) GetAccountBalance(accountID string) (decimal.Decimal, error) {
var balance decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN CAST(amount AS DECIMAL) ELSE -CAST(amount AS DECIMAL) END), 0) FROM account_transfers WHERE account_id = $1;`
err := r.db.Get(&balance, query, accountID)
return balance, err
}
func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
var total decimal.Decimal
query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN CAST(t.amount AS DECIMAL) ELSE -CAST(t.amount AS DECIMAL) END), 0)
FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;`
err := r.db.Get(&total, query, spaceID)
return total, err
}
func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) {
var transfers []*model.AccountTransferWithAccount
query := `SELECT t.id, t.account_id, t.amount, t.direction, t.note,
t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name
FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1
ORDER BY t.created_at DESC
LIMIT $2 OFFSET $3;`
err := r.db.Select(&transfers, query, spaceID, limit, offset)
if err != nil {
return nil, err
}
return transfers, nil
}
func (r *moneyAccountRepository) CountTransfersBySpaceID(spaceID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;`
err := r.db.Get(&count, query, spaceID)
return count, err
}

View file

@ -1,170 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, account.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Savings", fetched.Name)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestMoneyAccountRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
accounts, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, accounts, 2)
})
}
func TestMoneyAccountRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Original", user.ID)
account.Name = "Renamed"
err := repo.Update(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, "Renamed", fetched.Name)
})
}
func TestMoneyAccountRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "To Delete", user.ID)
err := repo.Delete(account.ID)
require.NoError(t, err)
_, err = repo.GetByID(account.ID)
assert.ErrorIs(t, err, ErrMoneyAccountNotFound)
})
}
func TestMoneyAccountRepository_CreateTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: account.ID,
Amount: decimal.RequireFromString("49.95"),
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
CreatedAt: time.Now(),
}
err := repo.CreateTransfer(transfer)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
require.Len(t, transfers, 1)
assert.Equal(t, transfer.ID, transfers[0].ID)
assert.True(t, decimal.RequireFromString("49.95").Equal(transfers[0].Amount))
assert.Equal(t, model.TransferDirectionDeposit, transfers[0].Direction)
})
}
func TestMoneyAccountRepository_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.25"), model.TransferDirectionDeposit, user.ID)
err := repo.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}
func TestMoneyAccountRepository_GetAccountBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.50"), model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("3.25"), model.TransferDirectionWithdrawal, user.ID)
balance, err := repo.GetAccountBalance(account.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("7.25").Equal(balance))
})
}
func TestMoneyAccountRepository_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("20.75"), model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("29.50"), model.TransferDirectionDeposit, user.ID)
total, err := repo.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("50.25").Equal(total))
})
}

View file

@ -1,83 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrPaymentMethodNotFound = errors.New("payment method not found")
)
type PaymentMethodRepository interface {
Create(method *model.PaymentMethod) error
GetByID(id string) (*model.PaymentMethod, error)
GetBySpaceID(spaceID string) ([]*model.PaymentMethod, error)
Update(method *model.PaymentMethod) error
Delete(id string) error
}
type paymentMethodRepository struct {
db *sqlx.DB
}
func NewPaymentMethodRepository(db *sqlx.DB) PaymentMethodRepository {
return &paymentMethodRepository{db: db}
}
func (r *paymentMethodRepository) Create(method *model.PaymentMethod) error {
query := `INSERT INTO payment_methods (id, space_id, name, type, last_four, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);`
_, err := r.db.Exec(query, method.ID, method.SpaceID, method.Name, method.Type, method.LastFour, method.CreatedBy, method.CreatedAt, method.UpdatedAt)
return err
}
func (r *paymentMethodRepository) GetByID(id string) (*model.PaymentMethod, error) {
method := &model.PaymentMethod{}
query := `SELECT * FROM payment_methods WHERE id = $1;`
err := r.db.Get(method, query, id)
if err == sql.ErrNoRows {
return nil, ErrPaymentMethodNotFound
}
return method, err
}
func (r *paymentMethodRepository) GetBySpaceID(spaceID string) ([]*model.PaymentMethod, error) {
var methods []*model.PaymentMethod
query := `SELECT * FROM payment_methods WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&methods, query, spaceID)
if err != nil {
return nil, err
}
return methods, nil
}
func (r *paymentMethodRepository) Update(method *model.PaymentMethod) error {
method.UpdatedAt = time.Now()
query := `UPDATE payment_methods SET name = $1, type = $2, last_four = $3, updated_at = $4 WHERE id = $5;`
result, err := r.db.Exec(query, method.Name, method.Type, method.LastFour, method.UpdatedAt, method.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrPaymentMethodNotFound
}
return err
}
func (r *paymentMethodRepository) Delete(id string) error {
query := `DELETE FROM payment_methods WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrPaymentMethodNotFound
}
return err
}

View file

@ -1,97 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
lastFour := "4242"
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Visa Gold",
Type: model.PaymentMethodTypeCredit,
LastFour: &lastFour,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, method.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Visa Gold", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, fetched.Type)
require.NotNil(t, fetched.LastFour)
assert.Equal(t, "4242", *fetched.LastFour)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestPaymentMethodRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit Card", model.PaymentMethodTypeDebit, user.ID)
methods, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
method.Name = "New Card"
method.Type = model.PaymentMethodTypeDebit
err := repo.Update(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, "New Card", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, fetched.Type)
})
}
func TestPaymentMethodRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "To Delete", model.PaymentMethodTypeCredit, user.ID)
err := repo.Delete(method.ID)
require.NoError(t, err)
_, err = repo.GetByID(method.ID)
assert.ErrorIs(t, err, ErrPaymentMethodNotFound)
})
}

View file

@ -1,107 +0,0 @@
package repository
import (
"database/sql"
"errors"
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrProfileNotFound = errors.New("profile not found")
)
type ProfileRepository interface {
Create(profile *model.Profile) (string, error)
ByUserID(userID string) (*model.Profile, error)
UpdateName(userID, name string) error
UpdateTimezone(userID, timezone string) error
}
type profileRepository struct {
db *sqlx.DB
}
func NewProfileRepository(db *sqlx.DB) *profileRepository {
return &profileRepository{db: db}
}
func (r *profileRepository) Create(profile *model.Profile) (string, error) {
if profile.CreatedAt.IsZero() {
profile.CreatedAt = time.Now()
}
if profile.UpdatedAt.IsZero() {
profile.UpdatedAt = time.Now()
}
_, err := r.db.Exec(`
INSERT INTO profiles (id, user_id, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
`, profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt)
if err != nil {
return "", err
}
return profile.ID, nil
}
func (r *profileRepository) ByUserID(userID string) (*model.Profile, error) {
var profile model.Profile
err := r.db.Get(&profile, `SELECT * FROM profiles WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrProfileNotFound
}
if err != nil {
return nil, err
}
return &profile, nil
}
func (r *profileRepository) UpdateTimezone(userID, timezone string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET timezone = $1, updated_at = $2
WHERE user_id = $3
`, timezone, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}
func (r *profileRepository) UpdateName(userID, name string) error {
result, err := r.db.Exec(`
UPDATE profiles
SET name = $1, updated_at = $2
WHERE user_id = $3
`, name, time.Now(), userID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("no profile found for user_id: %s", userID)
}
return nil
}

View file

@ -1,63 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-create@example.com", nil)
now := time.Now()
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "Test User",
CreatedAt: now,
UpdatedAt: now,
}
id, err := repo.Create(profile)
require.NoError(t, err)
assert.Equal(t, profile.ID, id)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "Test User", fetched.Name)
assert.Equal(t, user.ID, fetched.UserID)
})
}
func TestProfileRepository_UpdateName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-update@example.com", nil)
testutil.CreateTestProfile(t, dbi.DB, user.ID, "Old Name")
err := repo.UpdateName(user.ID, "New Name")
require.NoError(t, err)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", fetched.Name)
})
}
func TestProfileRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
_, err := repo.ByUserID("nonexistent-id")
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}

View file

@ -1,327 +0,0 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
var (
ErrReceiptNotFound = errors.New("receipt not found")
)
type ReceiptRepository interface {
CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
GetByID(id string) (*model.Receipt, error)
GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error)
CountByLoanID(loanID string) (int, error)
GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error)
CountBySpaceID(spaceID string) (int, error)
GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error)
GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error)
DeleteWithReversal(receiptID string) error
UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error
}
type receiptRepository struct {
db *sqlx.DB
}
func NewReceiptRepository(db *sqlx.DB) ReceiptRepository {
return &receiptRepository{db: db}
}
func (r *receiptRepository) CreateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Insert receipt
_, err = tx.Exec(
`INSERT INTO receipts (id, loan_id, space_id, description, total_amount, date, recurring_receipt_id, created_by, created_at, updated_at, total_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0);`,
receipt.ID, receipt.LoanID, receipt.SpaceID, receipt.Description, receipt.TotalAmount, receipt.Date, receipt.RecurringReceiptID, receipt.CreatedBy, receipt.CreatedAt, receipt.UpdatedAt,
)
if err != nil {
return err
}
// Insert balance expense if present
if balanceExpense != nil {
_, err = tx.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents)
Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *receiptRepository) GetByID(id string) (*model.Receipt, error) {
receipt := &model.Receipt{}
query := `SELECT * FROM receipts WHERE id = $1;`
err := r.db.Get(receipt, query, id)
if err == sql.ErrNoRows {
return nil, ErrReceiptNotFound
}
return receipt, err
}
func (r *receiptRepository) GetByLoanIDPaginated(loanID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE loan_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, loanID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountByLoanID(loanID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE loan_id = $1;`, loanID)
return count, err
}
func (r *receiptRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Receipt, error) {
var receipts []*model.Receipt
query := `SELECT * FROM receipts WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&receipts, query, spaceID, limit, offset)
return receipts, err
}
func (r *receiptRepository) CountBySpaceID(spaceID string) (int, error) {
var count int
err := r.db.Get(&count, `SELECT COUNT(*) FROM receipts WHERE space_id = $1;`, spaceID)
return count, err
}
func (r *receiptRepository) GetFundingSourcesByReceiptID(receiptID string) ([]model.ReceiptFundingSource, error) {
var sources []model.ReceiptFundingSource
query := `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`
err := r.db.Select(&sources, query, receiptID)
return sources, err
}
func (r *receiptRepository) GetFundingSourcesWithAccountsByReceiptIDs(receiptIDs []string) (map[string][]model.ReceiptFundingSourceWithAccount, error) {
if len(receiptIDs) == 0 {
return make(map[string][]model.ReceiptFundingSourceWithAccount), nil
}
type row struct {
ID string `db:"id"`
ReceiptID string `db:"receipt_id"`
SourceType model.FundingSourceType `db:"source_type"`
AccountID *string `db:"account_id"`
Amount decimal.Decimal `db:"amount"`
LinkedExpenseID *string `db:"linked_expense_id"`
LinkedTransferID *string `db:"linked_transfer_id"`
AccountName *string `db:"account_name"`
}
query, args, err := sqlx.In(`
SELECT rfs.id, rfs.receipt_id, rfs.source_type, rfs.account_id, rfs.amount,
rfs.linked_expense_id, rfs.linked_transfer_id,
ma.name AS account_name
FROM receipt_funding_sources rfs
LEFT JOIN money_accounts ma ON rfs.account_id = ma.id
WHERE rfs.receipt_id IN (?)
ORDER BY rfs.source_type ASC;
`, receiptIDs)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]model.ReceiptFundingSourceWithAccount)
for _, rw := range rows {
accountName := ""
if rw.AccountName != nil {
accountName = *rw.AccountName
}
result[rw.ReceiptID] = append(result[rw.ReceiptID], model.ReceiptFundingSourceWithAccount{
ReceiptFundingSource: model.ReceiptFundingSource{
ID: rw.ID,
ReceiptID: rw.ReceiptID,
SourceType: rw.SourceType,
AccountID: rw.AccountID,
Amount: rw.Amount,
LinkedExpenseID: rw.LinkedExpenseID,
LinkedTransferID: rw.LinkedTransferID,
},
AccountName: accountName,
})
}
return result, nil
}
func (r *receiptRepository) DeleteWithReversal(receiptID string) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Get all funding sources for this receipt
var sources []model.ReceiptFundingSource
if err := tx.Select(&sources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete linked expenses and transfers
for _, src := range sources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
// Delete funding sources (cascade would handle this, but be explicit)
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receiptID); err != nil {
return err
}
// Delete the receipt
if _, err := tx.Exec(`DELETE FROM receipts WHERE id = $1;`, receiptID); err != nil {
return err
}
return tx.Commit()
}
func (r *receiptRepository) UpdateWithSources(
receipt *model.Receipt,
sources []model.ReceiptFundingSource,
balanceExpense *model.Expense,
accountTransfers []*model.AccountTransfer,
) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
// Delete old linked records
var oldSources []model.ReceiptFundingSource
if err := tx.Select(&oldSources, `SELECT * FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
for _, src := range oldSources {
if src.LinkedExpenseID != nil {
if _, err := tx.Exec(`DELETE FROM expenses WHERE id = $1;`, *src.LinkedExpenseID); err != nil {
return err
}
}
if src.LinkedTransferID != nil {
if _, err := tx.Exec(`DELETE FROM account_transfers WHERE id = $1;`, *src.LinkedTransferID); err != nil {
return err
}
}
}
if _, err := tx.Exec(`DELETE FROM receipt_funding_sources WHERE receipt_id = $1;`, receipt.ID); err != nil {
return err
}
// Update receipt
_, err = tx.Exec(
`UPDATE receipts SET description = $1, total_amount = $2, date = $3, updated_at = $4 WHERE id = $5;`,
receipt.Description, receipt.TotalAmount, receipt.Date, receipt.UpdatedAt, receipt.ID,
)
if err != nil {
return err
}
// Insert new balance expense
if balanceExpense != nil {
_, err = tx.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, recurring_expense_id, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0);`,
balanceExpense.ID, balanceExpense.SpaceID, balanceExpense.CreatedBy, balanceExpense.Description, balanceExpense.Amount, balanceExpense.Type, balanceExpense.Date, balanceExpense.PaymentMethodID, balanceExpense.RecurringExpenseID, balanceExpense.CreatedAt, balanceExpense.UpdatedAt,
)
if err != nil {
return err
}
}
// Insert new account transfers
for _, transfer := range accountTransfers {
_, err = tx.Exec(
`INSERT INTO account_transfers (id, account_id, amount, direction, note, recurring_deposit_id, created_by, created_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0);`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.RecurringDepositID, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
return err
}
}
// Insert new funding sources
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO receipt_funding_sources (id, receipt_id, source_type, account_id, amount, linked_expense_id, linked_transfer_id, amount_cents)
Values ($1, $2, $3, $4, $5, $6, $7, 0);`,
src.ID, src.ReceiptID, src.SourceType, src.AccountID, src.Amount, src.LinkedExpenseID, src.LinkedTransferID,
)
if err != nil {
return err
}
}
return tx.Commit()
}

View file

@ -1,224 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringExpenseNotFound = errors.New("recurring expense not found")
)
type RecurringExpenseRepository interface {
Create(re *model.RecurringExpense, tagIDs []string) error
GetByID(id string) (*model.RecurringExpense, error)
GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error)
GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error)
GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error)
Update(re *model.RecurringExpense, tagIDs []string) error
Delete(id string) error
SetActive(id string, active bool) error
GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error)
UpdateNextOccurrence(id string, next time.Time) error
Deactivate(id string) error
}
type recurringExpenseRepository struct {
db *sqlx.DB
}
func NewRecurringExpenseRepository(db *sqlx.DB) RecurringExpenseRepository {
return &recurringExpenseRepository{db: db}
}
func (r *recurringExpenseRepository) Create(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `INSERT INTO recurring_expenses (id, space_id, created_by, description, amount, type, payment_method_id, frequency, start_date, end_date, next_occurrence, is_active, created_at, updated_at, amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 0);`
if _, err := tx.Exec(query, re.ID, re.SpaceID, re.CreatedBy, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.IsActive, re.CreatedAt, re.UpdatedAt); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *recurringExpenseRepository) GetByID(id string) (*model.RecurringExpense, error) {
re := &model.RecurringExpense{}
query := `SELECT * FROM recurring_expenses WHERE id = $1;`
err := r.db.Get(re, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringExpenseNotFound
}
return re, err
}
func (r *recurringExpenseRepository) GetBySpaceID(spaceID string) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringExpenseRepository) GetTagsByRecurringExpenseIDs(ids []string) (map[string][]*model.Tag, error) {
if len(ids) == 0 {
return make(map[string][]*model.Tag), nil
}
type row struct {
RecurringExpenseID string `db:"recurring_expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Color *string `db:"color"`
}
query, args, err := sqlx.In(`
SELECT ret.recurring_expense_id, t.id, t.space_id, t.name, t.color
FROM recurring_expense_tags ret
JOIN tags t ON ret.tag_id = t.id
WHERE ret.recurring_expense_id IN (?)
ORDER BY t.name;
`, ids)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]*model.Tag)
for _, rw := range rows {
result[rw.RecurringExpenseID] = append(result[rw.RecurringExpenseID], &model.Tag{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Color: rw.Color,
})
}
return result, nil
}
func (r *recurringExpenseRepository) GetPaymentMethodsByRecurringExpenseIDs(ids []string) (map[string]*model.PaymentMethod, error) {
if len(ids) == 0 {
return make(map[string]*model.PaymentMethod), nil
}
type row struct {
RecurringExpenseID string `db:"recurring_expense_id"`
ID string `db:"id"`
SpaceID string `db:"space_id"`
Name string `db:"name"`
Type model.PaymentMethodType `db:"type"`
LastFour *string `db:"last_four"`
}
query, args, err := sqlx.In(`
SELECT re.id AS recurring_expense_id, pm.id, pm.space_id, pm.name, pm.type, pm.last_four
FROM recurring_expenses re
JOIN payment_methods pm ON re.payment_method_id = pm.id
WHERE re.id IN (?) AND re.payment_method_id IS NOT NULL;
`, ids)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var rows []row
if err := r.db.Select(&rows, query, args...); err != nil {
return nil, err
}
result := make(map[string]*model.PaymentMethod)
for _, rw := range rows {
result[rw.RecurringExpenseID] = &model.PaymentMethod{
ID: rw.ID,
SpaceID: rw.SpaceID,
Name: rw.Name,
Type: rw.Type,
LastFour: rw.LastFour,
}
}
return result, nil
}
func (r *recurringExpenseRepository) Update(re *model.RecurringExpense, tagIDs []string) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
query := `UPDATE recurring_expenses SET description = $1, amount = $2, type = $3, payment_method_id = $4, frequency = $5, start_date = $6, end_date = $7, next_occurrence = $8, updated_at = $9 WHERE id = $10;`
if _, err := tx.Exec(query, re.Description, re.Amount, re.Type, re.PaymentMethodID, re.Frequency, re.StartDate, re.EndDate, re.NextOccurrence, re.UpdatedAt, re.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM recurring_expense_tags WHERE recurring_expense_id = $1;`, re.ID); err != nil {
return err
}
if len(tagIDs) > 0 {
tagQuery := `INSERT INTO recurring_expense_tags (recurring_expense_id, tag_id) VALUES ($1, $2);`
for _, tagID := range tagIDs {
if _, err := tx.Exec(tagQuery, re.ID, tagID); err != nil {
return err
}
}
}
return nil
})
}
func (r *recurringExpenseRepository) Delete(id string) error {
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
}
func (r *recurringExpenseRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_expenses SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringExpenseRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringExpenseRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringExpense, error) {
var results []*model.RecurringExpense
query := `SELECT * FROM recurring_expenses 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 *recurringExpenseRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_expenses SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}
func (r *recurringExpenseRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}

View file

@ -1,165 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrRecurringReceiptNotFound = errors.New("recurring receipt not found")
)
type RecurringReceiptRepository interface {
Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
GetByID(id string) (*model.RecurringReceipt, error)
GetByLoanID(loanID string) ([]*model.RecurringReceipt, error)
GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error)
GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error)
Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error
Delete(id string) error
SetActive(id string, active bool) error
Deactivate(id string) error
GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error)
GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error)
UpdateNextOccurrence(id string, next time.Time) error
}
type recurringReceiptRepository struct {
db *sqlx.DB
}
func NewRecurringReceiptRepository(db *sqlx.DB) RecurringReceiptRepository {
return &recurringReceiptRepository{db: db}
}
func (r *recurringReceiptRepository) Create(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`INSERT INTO recurring_receipts (id, loan_id, space_id, description, total_amount, frequency, start_date, end_date, next_occurrence, is_active, created_by, created_at, updated_at, total_amount_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 0);`,
rr.ID, rr.LoanID, rr.SpaceID, rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.IsActive, rr.CreatedBy, rr.CreatedAt, rr.UpdatedAt,
)
if err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) GetByID(id string) (*model.RecurringReceipt, error) {
rr := &model.RecurringReceipt{}
query := `SELECT * FROM recurring_receipts WHERE id = $1;`
err := r.db.Get(rr, query, id)
if err == sql.ErrNoRows {
return nil, ErrRecurringReceiptNotFound
}
return rr, err
}
func (r *recurringReceiptRepository) GetByLoanID(loanID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE loan_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, loanID)
return results, err
}
func (r *recurringReceiptRepository) GetBySpaceID(spaceID string) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE space_id = $1 ORDER BY is_active DESC, next_occurrence ASC;`
err := r.db.Select(&results, query, spaceID)
return results, err
}
func (r *recurringReceiptRepository) GetSourcesByRecurringReceiptID(id string) ([]model.RecurringReceiptSource, error) {
var sources []model.RecurringReceiptSource
query := `SELECT * FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`
err := r.db.Select(&sources, query, id)
return sources, err
}
func (r *recurringReceiptRepository) Update(rr *model.RecurringReceipt, sources []model.RecurringReceiptSource) error {
tx, err := r.db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(
`UPDATE recurring_receipts SET description = $1, total_amount = $2, frequency = $3, start_date = $4, end_date = $5, next_occurrence = $6, updated_at = $7 WHERE id = $8;`,
rr.Description, rr.TotalAmount, rr.Frequency, rr.StartDate, rr.EndDate, rr.NextOccurrence, rr.UpdatedAt, rr.ID,
)
if err != nil {
return err
}
// Replace sources
if _, err := tx.Exec(`DELETE FROM recurring_receipt_sources WHERE recurring_receipt_id = $1;`, rr.ID); err != nil {
return err
}
for _, src := range sources {
_, err = tx.Exec(
`INSERT INTO recurring_receipt_sources (id, recurring_receipt_id, source_type, account_id, amount, amount_cents)
VALUES ($1, $2, $3, $4, $5, 0);`,
src.ID, src.RecurringReceiptID, src.SourceType, src.AccountID, src.Amount,
)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *recurringReceiptRepository) Delete(id string) error {
_, err := r.db.Exec(`DELETE FROM recurring_receipts WHERE id = $1;`, id)
return err
}
func (r *recurringReceiptRepository) SetActive(id string, active bool) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET is_active = $1, updated_at = $2 WHERE id = $3;`, active, time.Now(), id)
return err
}
func (r *recurringReceiptRepository) Deactivate(id string) error {
return r.SetActive(id, false)
}
func (r *recurringReceiptRepository) GetDueRecurrences(now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts WHERE is_active = true AND next_occurrence <= $1;`
err := r.db.Select(&results, query, now)
return results, err
}
func (r *recurringReceiptRepository) GetDueRecurrencesForSpace(spaceID string, now time.Time) ([]*model.RecurringReceipt, error) {
var results []*model.RecurringReceipt
query := `SELECT * FROM recurring_receipts 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 *recurringReceiptRepository) UpdateNextOccurrence(id string, next time.Time) error {
_, err := r.db.Exec(`UPDATE recurring_receipts SET next_occurrence = $1, updated_at = $2 WHERE id = $3;`, next, time.Now(), id)
return err
}

View file

@ -1,83 +0,0 @@
package repository
import (
"database/sql"
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrShoppingListNotFound = errors.New("shopping list not found")
)
type ShoppingListRepository interface {
Create(list *model.ShoppingList) error
GetByID(id string) (*model.ShoppingList, error)
GetBySpaceID(spaceID string) ([]*model.ShoppingList, error)
Update(list *model.ShoppingList) error
Delete(id string) error
}
type shoppingListRepository struct {
db *sqlx.DB
}
func NewShoppingListRepository(db *sqlx.DB) ShoppingListRepository {
return &shoppingListRepository{db: db}
}
func (r *shoppingListRepository) Create(list *model.ShoppingList) error {
query := `INSERT INTO shopping_lists (id, space_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, list.ID, list.SpaceID, list.Name, list.CreatedAt, list.UpdatedAt)
return err
}
func (r *shoppingListRepository) GetByID(id string) (*model.ShoppingList, error) {
list := &model.ShoppingList{}
query := `SELECT * FROM shopping_lists WHERE id = $1;`
err := r.db.Get(list, query, id)
if err == sql.ErrNoRows {
return nil, ErrShoppingListNotFound
}
return list, err
}
func (r *shoppingListRepository) GetBySpaceID(spaceID string) ([]*model.ShoppingList, error) {
var lists []*model.ShoppingList
query := `SELECT * FROM shopping_lists WHERE space_id = $1 ORDER BY created_at DESC;`
err := r.db.Select(&lists, query, spaceID)
if err != nil {
return nil, err
}
return lists, nil
}
func (r *shoppingListRepository) Update(list *model.ShoppingList) error {
list.UpdatedAt = time.Now()
query := `UPDATE shopping_lists SET name = $1, updated_at = $2 WHERE id = $3;`
result, err := r.db.Exec(query, list.Name, list.UpdatedAt, list.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}
func (r *shoppingListRepository) Delete(id string) error {
query := `DELETE FROM shopping_lists WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrShoppingListNotFound
}
return err
}

View file

@ -1,93 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, list.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Groceries", fetched.Name)
})
}
func TestShoppingListRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list1 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List A")
// Small delay to ensure distinct created_at timestamps for ordering.
time.Sleep(10 * time.Millisecond)
list2 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List B")
lists, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, lists, 2)
// Ordered by created_at DESC, so list2 should be first.
assert.Equal(t, list2.ID, lists[0].ID)
assert.Equal(t, list1.ID, lists[1].ID)
})
}
func TestShoppingListRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Original Name")
list.Name = "Updated Name"
err := repo.Update(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Name", fetched.Name)
})
}
func TestShoppingListRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "To Delete")
err := repo.Delete(list.ID)
require.NoError(t, err)
_, err = repo.GetByID(list.ID)
assert.ErrorIs(t, err, ErrShoppingListNotFound)
})
}

View file

@ -22,7 +22,7 @@ type SpaceRepository interface {
IsMember(spaceID, userID string) (bool, error)
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
UpdateName(spaceID, name string) error
UpdateTimezone(spaceID, timezone string) error
Delete(spaceID string) error
}
@ -115,10 +115,9 @@ func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithPr
var members []*model.SpaceMemberWithProfile
query := `
SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at,
p.name, u.email
u.name, u.email
FROM space_members sm
JOIN users u ON sm.user_id = u.id
JOIN profiles p ON sm.user_id = p.user_id
WHERE sm.space_id = $1
ORDER BY sm.role DESC, sm.joined_at ASC;`
err := r.db.Select(&members, query, spaceID)
@ -131,11 +130,6 @@ func (r *spaceRepository) UpdateName(spaceID, name string) error {
return err
}
func (r *spaceRepository) UpdateTimezone(spaceID, timezone string) error {
query := `UPDATE spaces SET timezone = $1, updated_at = $2 WHERE id = $3;`
_, err := r.db.Exec(query, timezone, time.Now(), spaceID)
return err
}
func (r *spaceRepository) Delete(spaceID string) error {
query := `DELETE FROM spaces WHERE id = $1;`

View file

@ -95,8 +95,10 @@ func TestSpaceRepository_GetMembers(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-owner@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-member@example.com", "Member")
ownerName := "Owner"
memberName := "Member"
owner := testutil.CreateTestUserWithName(t, dbi.DB, "members-owner@example.com", &ownerName)
member := testutil.CreateTestUserWithName(t, dbi.DB, "members-member@example.com", &memberName)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
err := repo.AddMember(space.ID, member.ID, model.RoleMember)
@ -108,9 +110,11 @@ func TestSpaceRepository_GetMembers(t *testing.T) {
// The query orders by role DESC (owner first), then joined_at ASC.
assert.Equal(t, model.RoleOwner, members[0].Role)
assert.Equal(t, "Owner", members[0].Name)
require.NotNil(t, members[0].Name)
assert.Equal(t, "Owner", *members[0].Name)
assert.Equal(t, model.RoleMember, members[1].Role)
assert.Equal(t, "Member", members[1].Name)
require.NotNil(t, members[1].Name)
assert.Equal(t, "Member", *members[1].Name)
})
}

View file

@ -1,96 +0,0 @@
package repository
import (
"database/sql"
"errors"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var (
ErrTagNotFound = errors.New("tag not found")
ErrDuplicateTagName = errors.New("tag with that name already exists in this space")
)
type TagRepository interface {
Create(tag *model.Tag) error
GetByID(id string) (*model.Tag, error)
GetBySpaceID(spaceID string) ([]*model.Tag, error)
Update(tag *model.Tag) error
Delete(id string) error
}
type tagRepository struct {
db *sqlx.DB
}
func NewTagRepository(db *sqlx.DB) TagRepository {
return &tagRepository{db: db}
}
func (r *tagRepository) Create(tag *model.Tag) error {
query := `INSERT INTO tags (id, space_id, name, color, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6);`
_, err := r.db.Exec(query, tag.ID, tag.SpaceID, tag.Name, tag.Color, tag.CreatedAt, tag.UpdatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
return nil
}
func (r *tagRepository) GetByID(id string) (*model.Tag, error) {
tag := &model.Tag{}
query := `SELECT * FROM tags WHERE id = $1;`
err := r.db.Get(tag, query, id)
if err == sql.ErrNoRows {
return nil, ErrTagNotFound
}
return tag, err
}
func (r *tagRepository) GetBySpaceID(spaceID string) ([]*model.Tag, error) {
var tags []*model.Tag
query := `SELECT * FROM tags WHERE space_id = $1 ORDER BY name ASC;`
err := r.db.Select(&tags, query, spaceID)
if err != nil {
return nil, err
}
return tags, nil
}
func (r *tagRepository) Update(tag *model.Tag) error {
tag.UpdatedAt = time.Now()
query := `UPDATE tags SET name = $1, color = $2, updated_at = $3 WHERE id = $4;`
result, err := r.db.Exec(query, tag.Name, tag.Color, tag.UpdatedAt, tag.ID)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
return ErrDuplicateTagName
}
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}
func (r *tagRepository) Delete(id string) error {
query := `DELETE FROM tags WHERE id = $1;`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err == nil && rows == 0 {
return ErrTagNotFound
}
return err
}

View file

@ -1,120 +0,0 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Space")
color := "#ff0000"
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
Color: &color,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "Groceries", fetched.Name)
assert.Equal(t, &color, fetched.Color)
assert.Equal(t, space.ID, fetched.SpaceID)
})
}
func TestTagRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag List Space")
// Create tags with names that sort alphabetically: "Alpha" < "Beta".
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
tags, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
assert.Equal(t, "Alpha", tags[0].Name)
assert.Equal(t, "Beta", tags[1].Name)
})
}
func TestTagRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Tag", nil)
newColor := "#00ff00"
tag.Name = "New Tag"
tag.Color = &newColor
err := repo.Update(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "New Tag", fetched.Name)
assert.Equal(t, &newColor, fetched.Color)
})
}
func TestTagRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := repo.Delete(tag.ID)
require.NoError(t, err)
_, err = repo.GetByID(tag.ID)
assert.ErrorIs(t, err, ErrTagNotFound)
})
}
func TestTagRepository_DuplicateTagName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-dup@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Dup Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Duplicate", nil)
now := time.Now()
duplicate := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Duplicate",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(duplicate)
assert.ErrorIs(t, err, ErrDuplicateTagName)
})
}

View file

@ -31,9 +31,9 @@ func NewUserRepository(db *sqlx.DB) UserRepository {
}
func (r *userRepository) Create(user *model.User) (string, error) {
query := `INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5);`
query := `INSERT INTO users (id, email, name, password_hash, email_verified_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
_, err := r.db.Exec(query, user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt)
_, err := r.db.Exec(query, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt, user.UpdatedAt)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "duplicate key value") {
@ -70,9 +70,9 @@ func (r *userRepository) ByEmail(email string) (*model.User, error) {
}
func (r *userRepository) Update(user *model.User) error {
query := `UPDATE users SET email = $1, password_hash = $2, pending_email = $3, email_verified_at = $4 WHERE id = $5;`
query := `UPDATE users SET email = $1, name = $2, password_hash = $3, pending_email = $4, email_verified_at = $5, updated_at = $6 WHERE id = $7;`
_, err := r.db.Exec(query, user.Email, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.ID)
_, err := r.db.Exec(query, user.Email, user.Name, user.PasswordHash, user.PendingEmail, user.EmailVerifiedAt, user.UpdatedAt, user.ID)
return err
}

View file

@ -10,29 +10,10 @@ 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.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)
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
mux := http.NewServeMux()
@ -52,7 +33,6 @@ func SetupRoutes(a *app.App) http.Handler {
// Auth pages
authRateLimiter := middleware.RateLimitAuth()
crudLimiter := middleware.RateLimitCRUD()
mux.HandleFunc("GET /auth", middleware.RequireGuest(auth.AuthPage))
mux.HandleFunc("GET /auth/password", middleware.RequireGuest(auth.PasswordPage))
@ -65,117 +45,20 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("POST /auth/password", authRateLimiter(middleware.RequireGuest(auth.LoginWithPassword)))
mux.HandleFunc("POST /auth/logout", auth.Logout)
// Join via invite
mux.HandleFunc("GET /join/{token}", auth.JoinSpace)
// ====================================================================================
// PRIVATE ROUTES
// ====================================================================================
crudLimiter := middleware.RateLimitCRUD()
mux.HandleFunc("GET /auth/onboarding", middleware.RequireAuth(auth.OnboardingPage))
mux.Handle("POST /auth/onboarding", crudLimiter(http.HandlerFunc(middleware.RequireAuth(auth.CompleteOnboarding))))
mux.HandleFunc("GET /app/dashboard", middleware.Redirect("/app/spaces"))
mux.HandleFunc("GET /app/spaces", middleware.RequireAuth(space.DashboardPage))
mux.Handle("POST /app/spaces", crudLimiter(middleware.RequireAuth(space.CreateSpace)))
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
mux.HandleFunc("POST /app/settings/timezone", middleware.RequireAuth(settings.SetTimezone))
// Space routes — wrapping order: Auth(SpaceAccess(handler))
// Auth runs first (outer), then SpaceAccess (inner), then the handler.
sa := middleware.RequireSpaceAccess(a.SpaceService)
cl := crudLimiter
// Overview & Reports
spaceRoute(mux, sa, "GET /app/spaces/{spaceID}", space.OverviewPage)
spaceRoute(mux, sa, "GET /app/spaces/{spaceID}/reports", space.ReportsPage)
// 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)
// 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)
// 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)
// 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)
// 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)
// 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)
// 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)
// 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)
// 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)
spaceRouteLimited(mux, sa, cl, "DELETE /app/spaces/{spaceID}", spaceSettings.DeleteSpace)
// 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)
// 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)
// 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)
mux.HandleFunc("GET /join/{token}", spaceSettings.JoinSpace)
// 404
mux.HandleFunc("/{path...}", home.NotFoundPage)
@ -188,7 +71,7 @@ func SetupRoutes(a *app.App) http.Handler {
middleware.RequestLogging,
middleware.NoCacheDynamic,
middleware.CSRFProtection,
middleware.AuthMiddleware(a.AuthService, a.UserService, a.ProfileService),
middleware.AuthMiddleware(a.AuthService, a.UserService),
middleware.WithURLPath,
)

View file

@ -34,7 +34,6 @@ var (
type AuthService struct {
emailService *EmailService
userRepository repository.UserRepository
profileRepository repository.ProfileRepository
tokenRepository repository.TokenRepository
spaceService *SpaceService
jwtSecret string
@ -46,7 +45,6 @@ type AuthService struct {
func NewAuthService(
emailService *EmailService,
userRepository repository.UserRepository,
profileRepository repository.ProfileRepository,
tokenRepository repository.TokenRepository,
spaceService *SpaceService,
jwtSecret string,
@ -55,15 +53,14 @@ func NewAuthService(
isProduction bool,
) *AuthService {
return &AuthService{
emailService: emailService,
userRepository: userRepository,
profileRepository: profileRepository,
tokenRepository: tokenRepository,
spaceService: spaceService,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
emailService: emailService,
userRepository: userRepository,
tokenRepository: tokenRepository,
spaceService: spaceService,
jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
isProduction: isProduction,
isProduction: isProduction,
}
}
@ -233,34 +230,20 @@ func (s *AuthService) SendMagicLink(email string) error {
user, err := s.userRepository.ByEmail(email)
if err != nil {
// User doesn't exists - create a new passwordless account
// User doesn't exist - create a new passwordless account
if errors.Is(err, repository.ErrUserNotFound) {
now := time.Now()
user = &model.User{
ID: uuid.NewString(),
Email: email,
CreatedAt: now,
UpdatedAt: now,
}
_, err := s.userRepository.Create(user)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
slog.Info("new user created with id", "id", user.ID)
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "",
CreatedAt: now,
UpdatedAt: now,
}
_, err = s.profileRepository.Create(profile)
if err != nil {
return fmt.Errorf("failed to create profile: %w", err)
}
slog.Info("new passwordless user created", "email", email, "user_id", user.ID)
} else {
// user look up unexpected error
@ -291,10 +274,9 @@ func (s *AuthService) SendMagicLink(email string) error {
return fmt.Errorf("failed to create token: %w", err)
}
profile, err := s.profileRepository.ByUserID(user.ID)
name := ""
if err == nil && profile != nil {
name = profile.Name
if user.Name != nil {
name = *user.Name
}
err = s.emailService.SendMagicLinkEmail(user.Email, magicToken, name)
@ -341,12 +323,12 @@ func (s *AuthService) VerifyMagicLink(tokenString string) (*model.User, error) {
// NeedsOnboarding checks if user needs to complete onboarding (name not set)
func (s *AuthService) NeedsOnboarding(userID string) (bool, error) {
profile, err := s.profileRepository.ByUserID(userID)
user, err := s.userRepository.ByID(userID)
if err != nil {
return false, fmt.Errorf("failed to get profile: %w", err)
return false, fmt.Errorf("failed to get user: %w", err)
}
return profile.Name == "", nil
return user.Name == nil || *user.Name == "", nil
}
// CompleteOnboarding sets the user's name during onboarding
@ -358,17 +340,20 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return err
}
err = s.profileRepository.UpdateName(userID, name)
user, err := s.userRepository.ByID(userID)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
return fmt.Errorf("failed to get user: %w", err)
}
user, err := s.userRepository.ByID(userID)
if err == nil {
err = s.emailService.SendWelcomeEmail(user.Email, name)
if err != nil {
slog.Warn("failed to send welcome email", "error", err, "email", user.Email)
}
user.Name = &name
err = s.userRepository.Update(user)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
err = s.emailService.SendWelcomeEmail(user.Email, name)
if err != nil {
slog.Warn("failed to send welcome email", "error", err, "email", user.Email)
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name)

View file

@ -14,7 +14,6 @@ import (
func newTestAuthService(dbi testutil.DBInfo) *AuthService {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaceSvc := NewSpaceService(spaceRepo)
@ -22,7 +21,6 @@ func newTestAuthService(dbi testutil.DBInfo) *AuthService {
return NewAuthService(
emailSvc,
userRepo,
profileRepo,
tokenRepo,
spaceSvc,
cfg.JWTSecret,
@ -45,12 +43,6 @@ func TestAuthService_SendMagicLink(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "newuser@example.com", user.Email)
// Verify profile was created in DB
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "", profile.Name)
// Verify token was created in DB
var tokenCount int
err = dbi.DB.Get(&tokenCount, `SELECT COUNT(*) FROM tokens WHERE user_id = $1 AND type = $2`, user.ID, model.TokenTypeMagicLink)
@ -161,17 +153,18 @@ func TestAuthService_NeedsOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
// User with empty name needs onboarding
userEmpty, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "empty@example.com", "")
// User with no name needs onboarding
userEmpty := testutil.CreateTestUser(t, dbi.DB, "empty@example.com", nil)
needs, err := svc.NeedsOnboarding(userEmpty.ID)
require.NoError(t, err)
assert.True(t, needs)
// User with a name does not need onboarding
userNamed, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "named@example.com", "Jane Doe")
err = svc.CompleteOnboarding(userEmpty.ID, "Jane Doe")
require.NoError(t, err)
needs, err = svc.NeedsOnboarding(userNamed.ID)
needs, err = svc.NeedsOnboarding(userEmpty.ID)
require.NoError(t, err)
assert.False(t, needs)
})
@ -181,15 +174,16 @@ func TestAuthService_CompleteOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "onboard@example.com", "")
user := testutil.CreateTestUser(t, dbi.DB, "onboard@example.com", nil)
err := svc.CompleteOnboarding(user.ID, "New Name")
require.NoError(t, err)
// Verify profile name was updated
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
// Verify user name was updated
userRepo := repository.NewUserRepository(dbi.DB)
updated, err := userRepo.ByID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", profile.Name)
assert.NotNil(t, updated.Name)
assert.Equal(t, "New Name", *updated.Name)
})
}

View file

@ -1,186 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateBudgetDTO struct {
SpaceID string
TagIDs []string
Amount decimal.Decimal
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
CreatedBy string
}
type UpdateBudgetDTO struct {
ID string
TagIDs []string
Amount decimal.Decimal
Period model.BudgetPeriod
StartDate time.Time
EndDate *time.Time
}
type BudgetService struct {
budgetRepo repository.BudgetRepository
}
func NewBudgetService(budgetRepo repository.BudgetRepository) *BudgetService {
return &BudgetService{budgetRepo: budgetRepo}
}
func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("budget amount must be positive")
}
if len(dto.TagIDs) == 0 {
return nil, fmt.Errorf("at least one tag is required")
}
now := time.Now()
budget := &model.Budget{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Amount: dto.Amount,
Period: dto.Period,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsActive: true,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.budgetRepo.Create(budget, dto.TagIDs); err != nil {
return nil, err
}
return budget, nil
}
func (s *BudgetService) GetBudget(id string) (*model.Budget, error) {
return s.budgetRepo.GetByID(id)
}
func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWithSpent, error) {
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
// Collect budget IDs for batch tag fetch
budgetIDs := make([]string, len(budgets))
for i, b := range budgets {
budgetIDs[i] = b.ID
}
budgetTagsMap, err := s.budgetRepo.GetTagsByBudgetIDs(budgetIDs)
if err != nil {
return nil, err
}
result := make([]*model.BudgetWithSpent, 0, len(budgets))
for _, b := range budgets {
tags := budgetTagsMap[b.ID]
// Extract tag IDs for spending calculation
tagIDs := make([]string, len(tags))
for i, t := range tags {
tagIDs[i] = t.ID
}
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end)
if err != nil {
spent = decimal.Zero
}
var percentage float64
if b.Amount.GreaterThan(decimal.Zero) {
percentage, _ = spent.Div(b.Amount).Mul(decimal.NewFromInt(100)).Float64()
}
var status model.BudgetStatus
switch {
case percentage > 100:
status = model.BudgetStatusOver
case percentage >= 75:
status = model.BudgetStatusWarning
default:
status = model.BudgetStatusOnTrack
}
bws := &model.BudgetWithSpent{
Budget: *b,
Tags: tags,
Spent: spent,
Percentage: percentage,
Status: status,
}
result = append(result, bws)
}
return result, nil
}
func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("budget amount must be positive")
}
if len(dto.TagIDs) == 0 {
return nil, fmt.Errorf("at least one tag is required")
}
existing, err := s.budgetRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Amount = dto.Amount
existing.Period = dto.Period
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.budgetRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *BudgetService) DeleteBudget(id string) error {
return s.budgetRepo.Delete(id)
}
func GetCurrentPeriodBounds(period model.BudgetPeriod, now time.Time) (time.Time, time.Time) {
switch period {
case model.BudgetPeriodWeekly:
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start := now.AddDate(0, 0, -(weekday - 1))
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 6)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
case model.BudgetPeriodYearly:
start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
end := time.Date(now.Year(), 12, 31, 23, 59, 59, 0, now.Location())
return start, end
default: // monthly
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 1, -1)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, now.Location())
return start, end
}
}

View file

@ -1,245 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
Date time.Time
TagIDs []string
ItemIDs []string
PaymentMethodID *string
}
type UpdateExpenseDTO struct {
ID string
SpaceID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
Date time.Time
TagIDs []string
PaymentMethodID *string
}
const ExpensesPerPage = 25
type ExpenseService struct {
expenseRepo repository.ExpenseRepository
}
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
return &ExpenseService{
expenseRepo: expenseRepo,
}
}
func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
Amount: dto.Amount,
Type: dto.Type,
Date: dto.Date,
PaymentMethodID: dto.PaymentMethodID,
CreatedAt: now,
UpdatedAt: now,
}
err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs)
if err != nil {
return nil, err
}
return expense, nil
}
func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense, error) {
return s.expenseRepo.GetBySpaceID(spaceID)
}
func (s *ExpenseService) GetBalanceForSpace(spaceID string) (decimal.Decimal, error) {
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil {
return decimal.Zero, err
}
balance := decimal.Zero
for _, expense := range expenses {
if expense.Type == model.ExpenseTypeExpense {
balance = balance.Sub(expense.Amount)
} else if expense.Type == model.ExpenseTypeTopup {
balance = balance.Add(expense.Amount)
}
}
return balance, nil
}
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
}
func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) {
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, err
}
result := make([]*model.ExpenseWithTags, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTags{
Expense: *e,
Tags: tagsMap[e.ID],
}
}
return result, nil
}
func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTags, int, error) {
total, err := s.expenseRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ExpenseWithTags, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTags{
Expense: *e,
Tags: tagsMap[e.ID],
}
}
return result, totalPages, nil
}
func (s *ExpenseService) GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTagsAndMethod, int, error) {
total, err := s.expenseRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ExpensesPerPage)
expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset)
if err != nil {
return nil, 0, err
}
ids := make([]string, len(expenses))
for i, e := range expenses {
ids[i] = e.ID
}
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
methodsMap, err := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ExpenseWithTagsAndMethod, len(expenses))
for i, e := range expenses {
result[i] = &model.ExpenseWithTagsAndMethod{
Expense: *e,
Tags: tagsMap[e.ID],
PaymentMethod: methodsMap[e.ID],
}
}
return result, totalPages, nil
}
func (s *ExpenseService) GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error) {
return s.expenseRepo.GetPaymentMethodsByExpenseIDs(expenseIDs)
}
func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) {
return s.expenseRepo.GetByID(id)
}
func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs)
}
func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("expense description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.expenseRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.Amount = dto.Amount
existing.Type = dto.Type
existing.Date = dto.Date
existing.PaymentMethodID = dto.PaymentMethodID
existing.UpdatedAt = time.Now()
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *ExpenseService) DeleteExpense(id string, spaceID string) error {
if err := s.expenseRepo.Delete(id); err != nil {
return err
}
return nil
}

View file

@ -1,233 +0,0 @@
package service
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseService_CreateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Lunch",
Amount: decimal.RequireFromString("15.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
assert.NotEmpty(t, expense.ID)
assert.Equal(t, "Lunch", expense.Description)
assert.True(t, decimal.RequireFromString("15.49").Equal(expense.Amount))
assert.Equal(t, model.ExpenseTypeExpense, expense.Type)
})
}
func TestExpenseService_CreateExpense_EmptyDescription(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_CreateExpense_ZeroAmount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "Something",
Amount: decimal.Zero,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Paginate Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Transport", nil)
// Create expense with tag via the service
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Bus fare",
Amount: decimal.RequireFromString("2.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
// Create expense without tag
_, err = svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Coffee",
Amount: decimal.RequireFromString("5.01"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
results, totalPages, err := svc.GetExpensesWithTagsForSpacePaginated(space.ID, 1)
require.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, 1, totalPages)
// Verify at least one result has tags and one does not
var withTags, withoutTags int
for _, r := range results {
if len(r.Tags) > 0 {
withTags++
} else {
withoutTags++
}
}
assert.Equal(t, 1, withTags)
assert.Equal(t, 1, withoutTags)
})
}
func TestExpenseService_GetBalanceForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-balance@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Balance Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Topup", decimal.RequireFromString("100.50"), model.ExpenseTypeTopup)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", decimal.RequireFromString("30.75"), model.ExpenseTypeExpense)
balance, err := svc.GetBalanceForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("69.75").Equal(balance))
})
}
func TestExpenseService_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-bytag@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc ByTag Space")
tagColor := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Dining", &tagColor)
now := time.Now()
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Dinner",
Amount: decimal.RequireFromString("24.99"),
Type: model.ExpenseTypeExpense,
Date: now,
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
summaries, err := svc.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.True(t, decimal.RequireFromString("24.99").Equal(summaries[0].TotalAmount))
})
}
func TestExpenseService_UpdateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Update Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Old Description",
Amount: decimal.RequireFromString("10.75"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
updated, err := svc.UpdateExpense(UpdateExpenseDTO{
ID: created.ID,
SpaceID: space.ID,
Description: "New Description",
Amount: decimal.RequireFromString("19.49"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, "New Description", updated.Description)
assert.True(t, decimal.RequireFromString("19.49").Equal(updated.Amount))
})
}
func TestExpenseService_DeleteExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Delete Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Doomed Expense",
Amount: decimal.RequireFromString("4.99"),
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
err = svc.DeleteExpense(created.ID, space.ID)
require.NoError(t, err)
_, err = svc.GetExpense(created.ID)
assert.Error(t, err)
})
}

View file

@ -1,196 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateLoanDTO struct {
SpaceID string
UserID string
Name string
Description string
OriginalAmount decimal.Decimal
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
type UpdateLoanDTO struct {
ID string
Name string
Description string
OriginalAmount decimal.Decimal
InterestRateBps int
StartDate time.Time
EndDate *time.Time
}
const LoansPerPage = 25
type LoanService struct {
loanRepo repository.LoanRepository
receiptRepo repository.ReceiptRepository
}
func NewLoanService(loanRepo repository.LoanRepository, receiptRepo repository.ReceiptRepository) *LoanService {
return &LoanService{
loanRepo: loanRepo,
receiptRepo: receiptRepo,
}
}
func (s *LoanService) CreateLoan(dto CreateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
loan := &model.Loan{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: dto.Name,
Description: dto.Description,
OriginalAmount: dto.OriginalAmount,
InterestRateBps: dto.InterestRateBps,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
IsPaidOff: false,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.loanRepo.Create(loan); err != nil {
return nil, err
}
return loan, nil
}
func (s *LoanService) GetLoan(id string) (*model.Loan, error) {
return s.loanRepo.GetByID(id)
}
func (s *LoanService) GetLoanWithSummary(id string) (*model.LoanWithPaymentSummary, error) {
loan, err := s.loanRepo.GetByID(id)
if err != nil {
return nil, err
}
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(id)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(id)
if err != nil {
return nil, err
}
return &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaid: totalPaid,
Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount,
}, nil
}
func (s *LoanService) GetLoansWithSummaryForSpace(spaceID string) ([]*model.LoanWithPaymentSummary, error) {
loans, err := s.loanRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
return s.attachSummaries(loans)
}
func (s *LoanService) GetLoansWithSummaryForSpacePaginated(spaceID string, page int) ([]*model.LoanWithPaymentSummary, int, error) {
total, err := s.loanRepo.CountBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
totalPages := (total + LoansPerPage - 1) / LoansPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * LoansPerPage
loans, err := s.loanRepo.GetBySpaceIDPaginated(spaceID, LoansPerPage, offset)
if err != nil {
return nil, 0, err
}
result, err := s.attachSummaries(loans)
if err != nil {
return nil, 0, err
}
return result, totalPages, nil
}
func (s *LoanService) attachSummaries(loans []*model.Loan) ([]*model.LoanWithPaymentSummary, error) {
result := make([]*model.LoanWithPaymentSummary, len(loans))
for i, loan := range loans {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(loan.ID)
if err != nil {
return nil, err
}
receiptCount, err := s.loanRepo.GetReceiptCountForLoan(loan.ID)
if err != nil {
return nil, err
}
result[i] = &model.LoanWithPaymentSummary{
Loan: *loan,
TotalPaid: totalPaid,
Remaining: loan.OriginalAmount.Sub(totalPaid),
ReceiptCount: receiptCount,
}
}
return result, nil
}
func (s *LoanService) UpdateLoan(dto UpdateLoanDTO) (*model.Loan, error) {
if dto.Name == "" {
return nil, fmt.Errorf("loan name cannot be empty")
}
if dto.OriginalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.loanRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Name = dto.Name
existing.Description = dto.Description
existing.OriginalAmount = dto.OriginalAmount
existing.InterestRateBps = dto.InterestRateBps
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if err := s.loanRepo.Update(existing); err != nil {
return nil, err
}
return existing, nil
}
func (s *LoanService) DeleteLoan(id string) error {
return s.loanRepo.Delete(id)
}

View file

@ -1,191 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateMoneyAccountDTO struct {
SpaceID string
Name string
CreatedBy string
}
type UpdateMoneyAccountDTO struct {
ID string
Name string
}
type CreateTransferDTO struct {
AccountID string
Amount decimal.Decimal
Direction model.TransferDirection
Note string
CreatedBy string
}
type MoneyAccountService struct {
accountRepo repository.MoneyAccountRepository
}
func NewMoneyAccountService(accountRepo repository.MoneyAccountRepository) *MoneyAccountService {
return &MoneyAccountService{
accountRepo: accountRepo,
}
}
func (s *MoneyAccountService) CreateAccount(dto CreateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: name,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.accountRepo.Create(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) GetAccountsForSpace(spaceID string) ([]model.MoneyAccountWithBalance, error) {
accounts, err := s.accountRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
result := make([]model.MoneyAccountWithBalance, len(accounts))
for i, acct := range accounts {
balance, err := s.accountRepo.GetAccountBalance(acct.ID)
if err != nil {
return nil, err
}
result[i] = model.MoneyAccountWithBalance{
MoneyAccount: *acct,
Balance: balance,
}
}
return result, nil
}
func (s *MoneyAccountService) GetAccount(id string) (*model.MoneyAccount, error) {
return s.accountRepo.GetByID(id)
}
func (s *MoneyAccountService) UpdateAccount(dto UpdateMoneyAccountDTO) (*model.MoneyAccount, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("account name cannot be empty")
}
account, err := s.accountRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
account.Name = name
err = s.accountRepo.Update(account)
if err != nil {
return nil, err
}
return account, nil
}
func (s *MoneyAccountService) DeleteAccount(id string) error {
return s.accountRepo.Delete(id)
}
func (s *MoneyAccountService) CreateTransfer(dto CreateTransferDTO, availableSpaceBalance decimal.Decimal) (*model.AccountTransfer, error) {
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if dto.Direction != model.TransferDirectionDeposit && dto.Direction != model.TransferDirectionWithdrawal {
return nil, fmt.Errorf("invalid transfer direction")
}
if dto.Direction == model.TransferDirectionDeposit {
if dto.Amount.GreaterThan(availableSpaceBalance) {
return nil, fmt.Errorf("insufficient available balance")
}
}
if dto.Direction == model.TransferDirectionWithdrawal {
accountBalance, err := s.accountRepo.GetAccountBalance(dto.AccountID)
if err != nil {
return nil, err
}
if dto.Amount.GreaterThan(accountBalance) {
return nil, fmt.Errorf("insufficient account balance")
}
}
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: dto.AccountID,
Amount: dto.Amount,
Direction: dto.Direction,
Note: strings.TrimSpace(dto.Note),
CreatedBy: dto.CreatedBy,
CreatedAt: time.Now(),
}
err := s.accountRepo.CreateTransfer(transfer)
if err != nil {
return nil, err
}
return transfer, nil
}
func (s *MoneyAccountService) GetTransfersForAccount(accountID string) ([]*model.AccountTransfer, error) {
return s.accountRepo.GetTransfersByAccountID(accountID)
}
func (s *MoneyAccountService) DeleteTransfer(id string) error {
return s.accountRepo.DeleteTransfer(id)
}
func (s *MoneyAccountService) GetAccountBalance(accountID string) (decimal.Decimal, error) {
return s.accountRepo.GetAccountBalance(accountID)
}
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (decimal.Decimal, error) {
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
}
const TransfersPerPage = 25
func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page int) ([]*model.AccountTransferWithAccount, int, error) {
total, err := s.accountRepo.CountTransfersBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, TransfersPerPage)
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
if err != nil {
return nil, 0, err
}
return transfers, totalPages, nil
}

View file

@ -1,190 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountService_CreateAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Space")
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, account.ID)
assert.Equal(t, "Savings", account.Name)
assert.Equal(t, space.ID, account.SpaceID)
})
}
func TestMoneyAccountService_CreateAccount_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: "some-space",
Name: "",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, account)
})
}
func TestMoneyAccountService_GetAccountsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("49.95"), model.TransferDirectionDeposit, user.ID)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, "Checking", accounts[0].Name)
assert.True(t, decimal.RequireFromString("49.95").Equal(accounts[0].Balance))
})
}
func TestMoneyAccountService_CreateTransfer_Deposit(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deposit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Deposit Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Deposit Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("29.75"),
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
}, decimal.RequireFromString("100.50"))
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.True(t, decimal.RequireFromString("29.75").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction)
})
}
func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-insuf@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Insuf Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Insuf Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("50.25"),
Direction: model.TransferDirectionDeposit,
Note: "Too much",
CreatedBy: user.ID,
}, decimal.RequireFromString("10.50"))
assert.Error(t, err)
assert.Nil(t, transfer)
})
}
func TestMoneyAccountService_CreateTransfer_Withdrawal(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("49.75"), model.TransferDirectionDeposit, user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: decimal.RequireFromString("19.50"),
Direction: model.TransferDirectionWithdrawal,
Note: "Withdrawal",
CreatedBy: user.ID,
}, decimal.Zero)
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.True(t, decimal.RequireFromString("19.50").Equal(transfer.Amount))
assert.Equal(t, model.TransferDirectionWithdrawal, transfer.Direction)
})
}
func TestMoneyAccountService_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-total@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, decimal.RequireFromString("30.25"), model.TransferDirectionDeposit, user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 2", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, decimal.RequireFromString("19.50"), model.TransferDirectionDeposit, user.ID)
total, err := svc.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.True(t, decimal.RequireFromString("49.75").Equal(total))
})
}
func TestMoneyAccountService_DeleteAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Del Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Doomed Account", user.ID)
err := svc.DeleteAccount(account.ID)
require.NoError(t, err)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, accounts)
})
}
func TestMoneyAccountService_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, decimal.RequireFromString("10.25"), model.TransferDirectionDeposit, user.ID)
err := svc.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := svc.GetTransfersForAccount(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}

View file

@ -1,18 +0,0 @@
package service
// Paginate calculates pagination values from a page number, total count, and page size.
// Returns the adjusted page, total pages, and offset for the query.
func Paginate(page, total, perPage int) (adjustedPage, totalPages, offset int) {
totalPages = (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset = (page - 1) * perPage
return page, totalPages, offset
}

View file

@ -1,109 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type CreatePaymentMethodDTO struct {
SpaceID string
Name string
Type model.PaymentMethodType
LastFour string
CreatedBy string
}
type UpdatePaymentMethodDTO struct {
ID string
Name string
Type model.PaymentMethodType
LastFour string
}
type PaymentMethodService struct {
methodRepo repository.PaymentMethodRepository
}
func NewPaymentMethodService(methodRepo repository.PaymentMethodRepository) *PaymentMethodService {
return &PaymentMethodService{
methodRepo: methodRepo,
}
}
func (s *PaymentMethodService) CreateMethod(dto CreatePaymentMethodDTO) (*model.PaymentMethod, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("payment method name cannot be empty")
}
if dto.Type != model.PaymentMethodTypeCredit && dto.Type != model.PaymentMethodTypeDebit {
return nil, fmt.Errorf("invalid payment method type")
}
if len(dto.LastFour) != 4 {
return nil, fmt.Errorf("last four digits must be exactly 4 characters")
}
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
Name: name,
Type: dto.Type,
LastFour: &dto.LastFour,
CreatedBy: dto.CreatedBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.methodRepo.Create(method)
if err != nil {
return nil, err
}
return method, nil
}
func (s *PaymentMethodService) GetMethodsForSpace(spaceID string) ([]*model.PaymentMethod, error) {
return s.methodRepo.GetBySpaceID(spaceID)
}
func (s *PaymentMethodService) GetMethod(id string) (*model.PaymentMethod, error) {
return s.methodRepo.GetByID(id)
}
func (s *PaymentMethodService) UpdateMethod(dto UpdatePaymentMethodDTO) (*model.PaymentMethod, error) {
name := strings.TrimSpace(dto.Name)
if name == "" {
return nil, fmt.Errorf("payment method name cannot be empty")
}
if dto.Type != model.PaymentMethodTypeCredit && dto.Type != model.PaymentMethodTypeDebit {
return nil, fmt.Errorf("invalid payment method type")
}
if len(dto.LastFour) != 4 {
return nil, fmt.Errorf("last four digits must be exactly 4 characters")
}
method, err := s.methodRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
method.Name = name
method.Type = dto.Type
method.LastFour = &dto.LastFour
err = s.methodRepo.Update(method)
if err != nil {
return nil, err
}
return method, nil
}
func (s *PaymentMethodService) DeleteMethod(id string) error {
return s.methodRepo.Delete(id)
}

View file

@ -1,144 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodService_CreateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Space")
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: space.ID,
Name: "Visa Card",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, method.ID)
assert.Equal(t, "Visa Card", method.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, method.Type)
require.NotNil(t, method.LastFour)
assert.Equal(t, "4242", *method.LastFour)
})
}
func TestPaymentMethodService_CreateMethod_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidType(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Bad Type Card",
Type: "invalid",
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidLastFour(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Short Digits Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "12",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_GetMethodsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc List Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit", model.PaymentMethodTypeDebit, user.ID)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodService_UpdateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Update Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
updated, err := svc.UpdateMethod(UpdatePaymentMethodDTO{
ID: method.ID,
Name: "New Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "9999",
})
require.NoError(t, err)
assert.Equal(t, "New Card", updated.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, updated.Type)
require.NotNil(t, updated.LastFour)
assert.Equal(t, "9999", *updated.LastFour)
})
}
func TestPaymentMethodService_DeleteMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Delete Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Doomed Card", model.PaymentMethodTypeCredit, user.ID)
err := svc.DeleteMethod(method.ID)
require.NoError(t, err)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, methods)
})
}

View file

@ -1,32 +0,0 @@
package service
import (
"errors"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
var ErrInvalidTimezone = errors.New("invalid timezone")
type ProfileService struct {
profileRepository repository.ProfileRepository
}
func NewProfileService(profileRepository repository.ProfileRepository) *ProfileService {
return &ProfileService{
profileRepository: profileRepository,
}
}
func (s *ProfileService) ByUserID(userID string) (*model.Profile, error) {
return s.profileRepository.ByUserID(userID)
}
func (s *ProfileService) UpdateTimezone(userID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.profileRepository.UpdateTimezone(userID, timezone)
}

View file

@ -1,35 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileService_ByUserID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "profile@example.com", "Test User")
got, err := svc.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, profile.ID, got.ID)
assert.Equal(t, user.ID, got.UserID)
assert.Equal(t, "Test User", got.Name)
})
}
func TestProfileService_ByUserID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
_, err := svc.ByUserID("nonexistent-id")
assert.Error(t, err)
})
}

View file

@ -1,318 +0,0 @@
package service
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type FundingSourceDTO struct {
SourceType model.FundingSourceType
AccountID string
Amount decimal.Decimal
}
type CreateReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Date time.Time
FundingSources []FundingSourceDTO
RecurringReceiptID *string
}
type UpdateReceiptDTO struct {
ID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Date time.Time
FundingSources []FundingSourceDTO
}
const ReceiptsPerPage = 25
type ReceiptService struct {
receiptRepo repository.ReceiptRepository
loanRepo repository.LoanRepository
accountRepo repository.MoneyAccountRepository
}
func NewReceiptService(
receiptRepo repository.ReceiptRepository,
loanRepo repository.LoanRepository,
accountRepo repository.MoneyAccountRepository,
) *ReceiptService {
return &ReceiptService{
receiptRepo: receiptRepo,
loanRepo: loanRepo,
accountRepo: accountRepo,
}
}
func (s *ReceiptService) CreateReceipt(dto CreateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
// Validate funding sources sum to total
sum := decimal.Zero
for _, src := range dto.FundingSources {
if src.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount)
}
// Validate loan exists and is not paid off
loan, err := s.loanRepo.GetByID(dto.LoanID)
if err != nil {
return nil, fmt.Errorf("loan not found: %w", err)
}
if loan.IsPaidOff {
return nil, fmt.Errorf("loan is already paid off")
}
now := time.Now()
receipt := &model.Receipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmount: dto.TotalAmount,
Date: dto.Date,
RecurringReceiptID: dto.RecurringReceiptID,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(receipt, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.CreateWithSources(receipt, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Check if loan is now fully paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(dto.LoanID)
if err == nil && totalPaid.GreaterThanOrEqual(loan.OriginalAmount) {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
}
return &model.ReceiptWithSources{Receipt: *receipt, Sources: sources}, nil
}
func (s *ReceiptService) buildLinkedRecords(
receipt *model.Receipt,
fundingSources []FundingSourceDTO,
spaceID, userID, description string,
date time.Time,
) ([]model.ReceiptFundingSource, *model.Expense, []*model.AccountTransfer) {
now := time.Now()
var sources []model.ReceiptFundingSource
var balanceExpense *model.Expense
var accountTransfers []*model.AccountTransfer
for _, src := range fundingSources {
fs := model.ReceiptFundingSource{
ID: uuid.NewString(),
ReceiptID: receipt.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceBalance {
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: spaceID,
CreatedBy: userID,
Description: fmt.Sprintf("Loan payment: %s", description),
Amount: src.Amount,
Type: model.ExpenseTypeExpense,
Date: date,
CreatedAt: now,
UpdatedAt: now,
}
balanceExpense = expense
fs.LinkedExpenseID = &expense.ID
} else {
acctID := src.AccountID
fs.AccountID = &acctID
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: src.AccountID,
Amount: src.Amount,
Direction: model.TransferDirectionWithdrawal,
Note: fmt.Sprintf("Loan payment: %s", description),
CreatedBy: userID,
CreatedAt: now,
}
accountTransfers = append(accountTransfers, transfer)
fs.LinkedTransferID = &transfer.ID
}
sources = append(sources, fs)
}
return sources, balanceExpense, accountTransfers
}
func (s *ReceiptService) GetReceipt(id string) (*model.ReceiptWithSourcesAndAccounts, error) {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return nil, err
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs([]string{id})
if err != nil {
return nil, err
}
return &model.ReceiptWithSourcesAndAccounts{
Receipt: *receipt,
Sources: sourcesMap[id],
}, nil
}
func (s *ReceiptService) GetReceiptsForLoanPaginated(loanID string, page int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
total, err := s.receiptRepo.CountByLoanID(loanID)
if err != nil {
return nil, 0, err
}
totalPages := (total + ReceiptsPerPage - 1) / ReceiptsPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ReceiptsPerPage
receipts, err := s.receiptRepo.GetByLoanIDPaginated(loanID, ReceiptsPerPage, offset)
if err != nil {
return nil, 0, err
}
return s.attachSources(receipts, totalPages)
}
func (s *ReceiptService) attachSources(receipts []*model.Receipt, totalPages int) ([]*model.ReceiptWithSourcesAndAccounts, int, error) {
ids := make([]string, len(receipts))
for i, r := range receipts {
ids[i] = r.ID
}
sourcesMap, err := s.receiptRepo.GetFundingSourcesWithAccountsByReceiptIDs(ids)
if err != nil {
return nil, 0, err
}
result := make([]*model.ReceiptWithSourcesAndAccounts, len(receipts))
for i, r := range receipts {
result[i] = &model.ReceiptWithSourcesAndAccounts{
Receipt: *r,
Sources: sourcesMap[r.ID],
}
}
return result, totalPages, nil
}
func (s *ReceiptService) DeleteReceipt(id string, spaceID string) error {
receipt, err := s.receiptRepo.GetByID(id)
if err != nil {
return err
}
if receipt.SpaceID != spaceID {
return fmt.Errorf("receipt not found")
}
if err := s.receiptRepo.DeleteWithReversal(id); err != nil {
return err
}
// Check if loan should be un-marked as paid off
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(receipt.LoanID)
if err != nil {
return nil // receipt deleted successfully, paid-off check is best-effort
}
loan, err := s.loanRepo.GetByID(receipt.LoanID)
if err != nil {
return nil
}
if loan.IsPaidOff && totalPaid.LessThan(loan.OriginalAmount) {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
return nil
}
func (s *ReceiptService) UpdateReceipt(dto UpdateReceiptDTO) (*model.ReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
sum := decimal.Zero
for _, src := range dto.FundingSources {
if src.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("each funding source amount must be positive")
}
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts (%s) must equal total amount (%s)", sum, dto.TotalAmount)
}
existing, err := s.receiptRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
if existing.SpaceID != dto.SpaceID {
return nil, fmt.Errorf("receipt not found")
}
existing.Description = dto.Description
existing.TotalAmount = dto.TotalAmount
existing.Date = dto.Date
existing.UpdatedAt = time.Now()
sources, balanceExpense, accountTransfers := s.buildLinkedRecords(existing, dto.FundingSources, dto.SpaceID, dto.UserID, dto.Description, dto.Date)
if err := s.receiptRepo.UpdateWithSources(existing, sources, balanceExpense, accountTransfers); err != nil {
return nil, err
}
// Re-check paid-off status
loan, err := s.loanRepo.GetByID(existing.LoanID)
if err == nil {
totalPaid, err := s.loanRepo.GetTotalPaidForLoan(existing.LoanID)
if err == nil {
if totalPaid.GreaterThanOrEqual(loan.OriginalAmount) && !loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, true)
} else if totalPaid.LessThan(loan.OriginalAmount) && loan.IsPaidOff {
_ = s.loanRepo.SetPaidOff(loan.ID, false)
}
}
}
return &model.ReceiptWithSources{Receipt: *existing, Sources: sources}, nil
}

View file

@ -1,304 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateRecurringExpenseDTO struct {
SpaceID string
UserID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type UpdateRecurringExpenseDTO struct {
ID string
Description string
Amount decimal.Decimal
Type model.ExpenseType
PaymentMethodID *string
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
TagIDs []string
}
type RecurringExpenseService struct {
recurringRepo repository.RecurringExpenseRepository
expenseRepo repository.ExpenseRepository
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringExpenseService(recurringRepo repository.RecurringExpenseRepository, expenseRepo repository.ExpenseRepository, profileRepo repository.ProfileRepository, spaceRepo repository.SpaceRepository) *RecurringExpenseService {
return &RecurringExpenseService{
recurringRepo: recurringRepo,
expenseRepo: expenseRepo,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringExpenseService) CreateRecurringExpense(dto CreateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
now := time.Now()
re := &model.RecurringExpense{
ID: uuid.NewString(),
SpaceID: dto.SpaceID,
CreatedBy: dto.UserID,
Description: dto.Description,
Amount: dto.Amount,
Type: dto.Type,
PaymentMethodID: dto.PaymentMethodID,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.recurringRepo.Create(re, dto.TagIDs); err != nil {
return nil, err
}
return re, nil
}
func (s *RecurringExpenseService) GetRecurringExpense(id string) (*model.RecurringExpense, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringExpenseService) GetRecurringExpensesForSpace(spaceID string) ([]*model.RecurringExpense, error) {
return s.recurringRepo.GetBySpaceID(spaceID)
}
func (s *RecurringExpenseService) GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID string) ([]*model.RecurringExpenseWithTagsAndMethod, error) {
recs, err := s.recurringRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
ids := make([]string, len(recs))
for i, re := range recs {
ids[i] = re.ID
}
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
methodsMap, err := s.recurringRepo.GetPaymentMethodsByRecurringExpenseIDs(ids)
if err != nil {
return nil, err
}
result := make([]*model.RecurringExpenseWithTagsAndMethod, len(recs))
for i, re := range recs {
result[i] = &model.RecurringExpenseWithTagsAndMethod{
RecurringExpense: *re,
Tags: tagsMap[re.ID],
PaymentMethod: methodsMap[re.ID],
}
}
return result, nil
}
func (s *RecurringExpenseService) UpdateRecurringExpense(dto UpdateRecurringExpenseDTO) (*model.RecurringExpense, error) {
if dto.Description == "" {
return nil, fmt.Errorf("description cannot be empty")
}
if dto.Amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.Amount = dto.Amount
existing.Type = dto.Type
existing.PaymentMethodID = dto.PaymentMethodID
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
// Recalculate next occurrence if frequency or start changed
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
if err := s.recurringRepo.Update(existing, dto.TagIDs); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringExpenseService) DeleteRecurringExpense(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringExpenseService) ToggleRecurringExpense(id string) (*model.RecurringExpense, error) {
re, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !re.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
re.IsActive = newActive
return re, nil
}
func (s *RecurringExpenseService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurrences: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, localNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) ProcessDueRecurrencesForSpace(spaceID string, now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrencesForSpace(spaceID, now)
if err != nil {
return fmt.Errorf("failed to get due recurrences for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, re := range dues {
localNow := s.getLocalNow(re.SpaceID, re.CreatedBy, now, tzCache)
if err := s.processRecurrence(re, localNow); err != nil {
slog.Error("failed to process recurring expense", "id", re.ID, "error", err)
}
}
return nil
}
func (s *RecurringExpenseService) processRecurrence(re *model.RecurringExpense, now time.Time) error {
// Get tag IDs for this recurring expense
tagsMap, err := s.recurringRepo.GetTagsByRecurringExpenseIDs([]string{re.ID})
if err != nil {
return err
}
var tagIDs []string
for _, t := range tagsMap[re.ID] {
tagIDs = append(tagIDs, t.ID)
}
// Generate expenses for each missed occurrence up to now
for !re.NextOccurrence.After(now) {
// Check if end_date has been passed
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
return s.recurringRepo.Deactivate(re.ID)
}
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: re.SpaceID,
CreatedBy: re.CreatedBy,
Description: re.Description,
Amount: re.Amount,
Type: re.Type,
Date: re.NextOccurrence,
PaymentMethodID: re.PaymentMethodID,
RecurringExpenseID: &re.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.expenseRepo.Create(expense, tagIDs, nil); err != nil {
return fmt.Errorf("failed to create expense from recurring: %w", err)
}
re.NextOccurrence = AdvanceDate(re.NextOccurrence, re.Frequency)
}
// Check if the new next occurrence exceeds end date
if re.EndDate != nil && re.NextOccurrence.After(*re.EndDate) {
if err := s.recurringRepo.Deactivate(re.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(re.ID, re.NextOccurrence)
}
// getLocalNow resolves the effective timezone for a recurring expense.
// Resolution order: space timezone → user profile timezone → UTC.
func (s *RecurringExpenseService) 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 AdvanceDate(date time.Time, freq model.Frequency) time.Time {
switch freq {
case model.FrequencyDaily:
return date.AddDate(0, 0, 1)
case model.FrequencyWeekly:
return date.AddDate(0, 0, 7)
case model.FrequencyBiweekly:
return date.AddDate(0, 0, 14)
case model.FrequencyMonthly:
return date.AddDate(0, 1, 0)
case model.FrequencyYearly:
return date.AddDate(1, 0, 0)
default:
return date.AddDate(0, 1, 0)
}
}

View file

@ -1,324 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type CreateRecurringReceiptDTO struct {
LoanID string
SpaceID string
UserID string
Description string
TotalAmount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type UpdateRecurringReceiptDTO struct {
ID string
Description string
TotalAmount decimal.Decimal
Frequency model.Frequency
StartDate time.Time
EndDate *time.Time
FundingSources []FundingSourceDTO
}
type RecurringReceiptService struct {
recurringRepo repository.RecurringReceiptRepository
receiptService *ReceiptService
loanRepo repository.LoanRepository
profileRepo repository.ProfileRepository
spaceRepo repository.SpaceRepository
}
func NewRecurringReceiptService(
recurringRepo repository.RecurringReceiptRepository,
receiptService *ReceiptService,
loanRepo repository.LoanRepository,
profileRepo repository.ProfileRepository,
spaceRepo repository.SpaceRepository,
) *RecurringReceiptService {
return &RecurringReceiptService{
recurringRepo: recurringRepo,
receiptService: receiptService,
loanRepo: loanRepo,
profileRepo: profileRepo,
spaceRepo: spaceRepo,
}
}
func (s *RecurringReceiptService) CreateRecurringReceipt(dto CreateRecurringReceiptDTO) (*model.RecurringReceiptWithSources, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
if len(dto.FundingSources) == 0 {
return nil, fmt.Errorf("at least one funding source is required")
}
sum := decimal.Zero
for _, src := range dto.FundingSources {
sum = sum.Add(src.Amount)
}
if !sum.Equal(dto.TotalAmount) {
return nil, fmt.Errorf("funding source amounts must equal total amount")
}
now := time.Now()
rr := &model.RecurringReceipt{
ID: uuid.NewString(),
LoanID: dto.LoanID,
SpaceID: dto.SpaceID,
Description: dto.Description,
TotalAmount: dto.TotalAmount,
Frequency: dto.Frequency,
StartDate: dto.StartDate,
EndDate: dto.EndDate,
NextOccurrence: dto.StartDate,
IsActive: true,
CreatedBy: dto.UserID,
CreatedAt: now,
UpdatedAt: now,
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: rr.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Create(rr, sources); err != nil {
return nil, err
}
return &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}, nil
}
func (s *RecurringReceiptService) GetRecurringReceipt(id string) (*model.RecurringReceipt, error) {
return s.recurringRepo.GetByID(id)
}
func (s *RecurringReceiptService) GetRecurringReceiptsForLoan(loanID string) ([]*model.RecurringReceipt, error) {
return s.recurringRepo.GetByLoanID(loanID)
}
func (s *RecurringReceiptService) GetRecurringReceiptsWithSourcesForLoan(loanID string) ([]*model.RecurringReceiptWithSources, error) {
rrs, err := s.recurringRepo.GetByLoanID(loanID)
if err != nil {
return nil, err
}
result := make([]*model.RecurringReceiptWithSources, len(rrs))
for i, rr := range rrs {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return nil, err
}
result[i] = &model.RecurringReceiptWithSources{
RecurringReceipt: *rr,
Sources: sources,
}
}
return result, nil
}
func (s *RecurringReceiptService) UpdateRecurringReceipt(dto UpdateRecurringReceiptDTO) (*model.RecurringReceipt, error) {
if dto.TotalAmount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("amount must be positive")
}
existing, err := s.recurringRepo.GetByID(dto.ID)
if err != nil {
return nil, err
}
existing.Description = dto.Description
existing.TotalAmount = dto.TotalAmount
existing.Frequency = dto.Frequency
existing.StartDate = dto.StartDate
existing.EndDate = dto.EndDate
existing.UpdatedAt = time.Now()
if existing.NextOccurrence.Before(dto.StartDate) {
existing.NextOccurrence = dto.StartDate
}
sources := make([]model.RecurringReceiptSource, len(dto.FundingSources))
for i, src := range dto.FundingSources {
sources[i] = model.RecurringReceiptSource{
ID: uuid.NewString(),
RecurringReceiptID: existing.ID,
SourceType: src.SourceType,
Amount: src.Amount,
}
if src.SourceType == model.FundingSourceAccount {
acctID := src.AccountID
sources[i].AccountID = &acctID
}
}
if err := s.recurringRepo.Update(existing, sources); err != nil {
return nil, err
}
return existing, nil
}
func (s *RecurringReceiptService) DeleteRecurringReceipt(id string) error {
return s.recurringRepo.Delete(id)
}
func (s *RecurringReceiptService) ToggleRecurringReceipt(id string) (*model.RecurringReceipt, error) {
rr, err := s.recurringRepo.GetByID(id)
if err != nil {
return nil, err
}
newActive := !rr.IsActive
if err := s.recurringRepo.SetActive(id, newActive); err != nil {
return nil, err
}
rr.IsActive = newActive
return rr, nil
}
func (s *RecurringReceiptService) ProcessDueRecurrences(now time.Time) error {
dues, err := s.recurringRepo.GetDueRecurrences(now)
if err != nil {
return fmt.Errorf("failed to get due recurring receipts: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) 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 receipts for space: %w", err)
}
tzCache := make(map[string]*time.Location)
for _, rr := range dues {
localNow := s.getLocalNow(rr.SpaceID, rr.CreatedBy, now, tzCache)
if err := s.processRecurrence(rr, localNow); err != nil {
slog.Error("failed to process recurring receipt", "id", rr.ID, "error", err)
}
}
return nil
}
func (s *RecurringReceiptService) processRecurrence(rr *model.RecurringReceipt, now time.Time) error {
sources, err := s.recurringRepo.GetSourcesByRecurringReceiptID(rr.ID)
if err != nil {
return err
}
for !rr.NextOccurrence.After(now) {
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
return s.recurringRepo.Deactivate(rr.ID)
}
// Check if loan is already paid off
loan, err := s.loanRepo.GetByID(rr.LoanID)
if err != nil {
return fmt.Errorf("failed to get loan: %w", err)
}
if loan.IsPaidOff {
return s.recurringRepo.Deactivate(rr.ID)
}
// Build funding source DTOs from template
fundingSources := make([]FundingSourceDTO, len(sources))
for i, src := range sources {
accountID := ""
if src.AccountID != nil {
accountID = *src.AccountID
}
fundingSources[i] = FundingSourceDTO{
SourceType: src.SourceType,
AccountID: accountID,
Amount: src.Amount,
}
}
rrID := rr.ID
dto := CreateReceiptDTO{
LoanID: rr.LoanID,
SpaceID: rr.SpaceID,
UserID: rr.CreatedBy,
Description: rr.Description,
TotalAmount: rr.TotalAmount,
Date: rr.NextOccurrence,
FundingSources: fundingSources,
RecurringReceiptID: &rrID,
}
if _, err := s.receiptService.CreateReceipt(dto); err != nil {
slog.Warn("recurring receipt skipped", "id", rr.ID, "error", err)
}
rr.NextOccurrence = AdvanceDate(rr.NextOccurrence, rr.Frequency)
}
if rr.EndDate != nil && rr.NextOccurrence.After(*rr.EndDate) {
if err := s.recurringRepo.Deactivate(rr.ID); err != nil {
return err
}
}
return s.recurringRepo.UpdateNextOccurrence(rr.ID, rr.NextOccurrence)
}
func (s *RecurringReceiptService) 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)
}

View file

@ -1,105 +0,0 @@
package service
import (
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
)
type ReportService struct {
expenseRepo repository.ExpenseRepository
}
func NewReportService(expenseRepo repository.ExpenseRepository) *ReportService {
return &ReportService{expenseRepo: expenseRepo}
}
type DateRange struct {
Label string
Key string
From time.Time
To time.Time
}
func (s *ReportService) GetSpendingReport(spaceID string, from, to time.Time) (*model.SpendingReport, error) {
byTag, err := s.expenseRepo.GetExpensesByTag(spaceID, from, to)
if err != nil {
return nil, err
}
daily, err := s.expenseRepo.GetDailySpending(spaceID, from, to)
if err != nil {
return nil, err
}
monthly, err := s.expenseRepo.GetMonthlySpending(spaceID, from, to)
if err != nil {
return nil, err
}
topExpenses, err := s.expenseRepo.GetTopExpenses(spaceID, from, to, 10)
if err != nil {
return nil, err
}
// Get tags and payment methods for top expenses
ids := make([]string, len(topExpenses))
for i, e := range topExpenses {
ids[i] = e.ID
}
tagsMap, _ := s.expenseRepo.GetTagsByExpenseIDs(ids)
methodsMap, _ := s.expenseRepo.GetPaymentMethodsByExpenseIDs(ids)
topWithTags := make([]*model.ExpenseWithTagsAndMethod, len(topExpenses))
for i, e := range topExpenses {
topWithTags[i] = &model.ExpenseWithTagsAndMethod{
Expense: *e,
Tags: tagsMap[e.ID],
PaymentMethod: methodsMap[e.ID],
}
}
byPaymentMethod, err := s.expenseRepo.GetExpensesByPaymentMethod(spaceID, from, to)
if err != nil {
return nil, err
}
totalIncome, totalExpenses, err := s.expenseRepo.GetIncomeVsExpenseSummary(spaceID, from, to)
if err != nil {
return nil, err
}
return &model.SpendingReport{
ByTag: byTag,
ByPaymentMethod: byPaymentMethod,
DailySpending: daily,
MonthlySpending: monthly,
TopExpenses: topWithTags,
TotalIncome: totalIncome,
TotalExpenses: totalExpenses,
NetBalance: totalIncome.Sub(totalExpenses),
}, nil
}
func GetPresetDateRanges(now time.Time) []DateRange {
thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
thisMonthEnd := thisMonthStart.AddDate(0, 1, -1)
thisMonthEnd = time.Date(thisMonthEnd.Year(), thisMonthEnd.Month(), thisMonthEnd.Day(), 23, 59, 59, 0, now.Location())
lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
lastMonthEnd := thisMonthStart.AddDate(0, 0, -1)
lastMonthEnd = time.Date(lastMonthEnd.Year(), lastMonthEnd.Month(), lastMonthEnd.Day(), 23, 59, 59, 0, now.Location())
last3MonthsStart := thisMonthStart.AddDate(0, -2, 0)
yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
return []DateRange{
{Label: "This Month", Key: "this_month", From: thisMonthStart, To: thisMonthEnd},
{Label: "Last Month", Key: "last_month", From: lastMonthStart, To: lastMonthEnd},
{Label: "Last 3 Months", Key: "last_3_months", From: last3MonthsStart, To: thisMonthEnd},
{Label: "This Year", Key: "this_year", From: yearStart, To: thisMonthEnd},
}
}

View file

@ -1,205 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type ShoppingListService struct {
listRepo repository.ShoppingListRepository
itemRepo repository.ListItemRepository
}
func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService {
return &ShoppingListService{
listRepo: listRepo,
itemRepo: itemRepo,
}
}
// List methods
func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
err := s.listRepo.Create(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) GetListsForSpace(spaceID string) ([]*model.ShoppingList, error) {
return s.listRepo.GetBySpaceID(spaceID)
}
func (s *ShoppingListService) GetList(listID string) (*model.ShoppingList, error) {
return s.listRepo.GetByID(listID)
}
func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingList, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("list name cannot be empty")
}
list, err := s.listRepo.GetByID(listID)
if err != nil {
return nil, err
}
list.Name = name
err = s.listRepo.Update(list)
if err != nil {
return nil, err
}
return list, nil
}
func (s *ShoppingListService) DeleteList(listID string) error {
// First delete all items in the list
err := s.itemRepo.DeleteByListID(listID)
if err != nil {
return fmt.Errorf("failed to delete items in list: %w", err)
}
// Then delete the list itself
return s.listRepo.Delete(listID)
}
// Item methods
func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: listID,
Name: name,
IsChecked: false,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
err := s.itemRepo.Create(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) GetItem(itemID string) (*model.ListItem, error) {
return s.itemRepo.GetByID(itemID)
}
func (s *ShoppingListService) GetItemsForList(listID string) ([]*model.ListItem, error) {
return s.itemRepo.GetByListID(listID)
}
const ItemsPerCardPage = 5
func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int) ([]*model.ListItem, int, error) {
total, err := s.itemRepo.CountByListID(listID)
if err != nil {
return nil, 0, err
}
page, totalPages, offset := Paginate(page, total, ItemsPerCardPage)
items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset)
if err != nil {
return nil, 0, err
}
return items, totalPages, nil
}
func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("item name cannot be empty")
}
item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return nil, err
}
item.Name = name
item.IsChecked = isChecked
err = s.itemRepo.Update(item)
if err != nil {
return nil, err
}
return item, nil
}
func (s *ShoppingListService) CheckItem(itemID string) error {
item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return err
}
item.IsChecked = true
return s.itemRepo.Update(item)
}
func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]model.ListWithUncheckedItems, error) {
lists, err := s.listRepo.GetBySpaceID(spaceID)
if err != nil {
return nil, err
}
var result []model.ListWithUncheckedItems
for _, list := range lists {
items, err := s.itemRepo.GetByListID(list.ID)
if err != nil {
return nil, err
}
var unchecked []*model.ListItem
for _, item := range items {
if !item.IsChecked {
unchecked = append(unchecked, item)
}
}
if len(unchecked) > 0 {
result = append(result, model.ListWithUncheckedItems{
List: list,
Items: unchecked,
})
}
}
return result, nil
}
func (s *ShoppingListService) DeleteItem(itemID string) error {
return s.itemRepo.Delete(itemID)
}

View file

@ -1,204 +0,0 @@
package service
import (
"fmt"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListService_CreateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Space")
list, err := svc.CreateList(space.ID, "Weekly Groceries")
require.NoError(t, err)
assert.NotEmpty(t, list.ID)
assert.Equal(t, "Weekly Groceries", list.Name)
assert.Equal(t, space.ID, list.SpaceID)
})
}
func TestShoppingListService_CreateList_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Empty Space")
list, err := svc.CreateList(space.ID, "")
assert.Error(t, err)
assert.Nil(t, list)
})
}
func TestShoppingListService_GetList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-get@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Get Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Seeded List")
list, err := svc.GetList(seeded.ID)
require.NoError(t, err)
assert.Equal(t, seeded.ID, list.ID)
assert.Equal(t, "Seeded List", list.Name)
})
}
func TestShoppingListService_UpdateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Update Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Old Name")
updated, err := svc.UpdateList(seeded.ID, "New Name")
require.NoError(t, err)
assert.Equal(t, "New Name", updated.Name)
})
}
func TestShoppingListService_DeleteList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Del Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Doomed List")
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 1", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 2", user.ID)
err := svc.DeleteList(seeded.ID)
require.NoError(t, err)
_, err = svc.GetList(seeded.ID)
assert.Error(t, err)
items, err := itemRepo.GetByListID(seeded.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}
func TestShoppingListService_AddItemToList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-additem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc AddItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Add Item List")
item, err := svc.AddItemToList(seeded.ID, "Milk", user.ID)
require.NoError(t, err)
assert.NotEmpty(t, item.ID)
assert.Equal(t, "Milk", item.Name)
assert.Equal(t, seeded.ID, item.ListID)
assert.False(t, item.IsChecked)
})
}
func TestShoppingListService_GetItemsForListPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Paginate Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Paginate List")
for i := 0; i < 6; i++ {
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, fmt.Sprintf("Item %d", i), user.ID)
}
items, totalPages, err := svc.GetItemsForListPaginated(seeded.ID, 1)
require.NoError(t, err)
assert.Len(t, items, 5)
assert.Equal(t, 2, totalPages)
})
}
func TestShoppingListService_CheckItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-check@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Check Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Check List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Check Me", user.ID)
err := svc.CheckItem(item.ID)
require.NoError(t, err)
fetched, err := svc.GetItem(item.ID)
require.NoError(t, err)
assert.True(t, fetched.IsChecked)
})
}
func TestShoppingListService_GetListsWithUncheckedItems(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-unchecked@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Unchecked Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Unchecked List")
checkedItem := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Checked Item", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Unchecked Item", user.ID)
_, err := dbi.DB.Exec("UPDATE list_items SET is_checked = true WHERE id = $1", checkedItem.ID)
require.NoError(t, err)
result, err := svc.GetListsWithUncheckedItems(space.ID)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, seeded.ID, result[0].List.ID)
require.Len(t, result[0].Items, 1)
assert.Equal(t, "Unchecked Item", result[0].Items[0].Name)
})
}
func TestShoppingListService_DeleteItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-delitem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc DelItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "DelItem List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Doomed Item", user.ID)
err := svc.DeleteItem(item.ID)
require.NoError(t, err)
_, err = svc.GetItem(item.ID)
assert.Error(t, err)
})
}

View file

@ -111,13 +111,6 @@ func (s *SpaceService) UpdateSpaceName(spaceID, name string) error {
return s.spaceRepo.UpdateName(spaceID, name)
}
// UpdateSpaceTimezone updates the timezone of a space.
func (s *SpaceService) UpdateSpaceTimezone(spaceID, timezone string) error {
if _, err := time.LoadLocation(timezone); err != nil {
return ErrInvalidTimezone
}
return s.spaceRepo.UpdateTimezone(spaceID, timezone)
}
// DeleteSpace permanently deletes a space and all its associated data.
func (s *SpaceService) DeleteSpace(spaceID string) error {

View file

@ -98,8 +98,10 @@ func TestSpaceService_GetMembers(t *testing.T) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "owner-members@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "member-members@example.com", "Member")
ownerName := "Owner"
memberName := "Member"
owner := testutil.CreateTestUserWithName(t, dbi.DB, "owner-members@example.com", &ownerName)
member := testutil.CreateTestUserWithName(t, dbi.DB, "member-members@example.com", &memberName)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
// Add second user as a member
@ -115,9 +117,11 @@ func TestSpaceService_GetMembers(t *testing.T) {
// The query orders by role DESC (owner first), then joined_at ASC
assert.Equal(t, model.RoleOwner, members[0].Role)
assert.Equal(t, "Owner", members[0].Name)
require.NotNil(t, members[0].Name)
assert.Equal(t, "Owner", *members[0].Name)
assert.Equal(t, model.RoleMember, members[1].Role)
assert.Equal(t, "Member", members[1].Name)
require.NotNil(t, members[1].Name)
assert.Equal(t, "Member", *members[1].Name)
})
}

View file

@ -1,81 +0,0 @@
package service
import (
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type TagService struct {
tagRepo repository.TagRepository
}
func NewTagService(tagRepo repository.TagRepository) *TagService {
return &TagService{tagRepo: tagRepo}
}
func NormalizeTagName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *TagService) CreateTag(spaceID, name string, color *string) (*model.Tag, error) {
name = NormalizeTagName(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
err := s.tagRepo.Create(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) GetTagsForSpace(spaceID string) ([]*model.Tag, error) {
return s.tagRepo.GetBySpaceID(spaceID)
}
func (s *TagService) GetTagByID(id string) (*model.Tag, error) {
return s.tagRepo.GetByID(id)
}
func (s *TagService) UpdateTag(id, name string, color *string) (*model.Tag, error) {
name = NormalizeTagName(name)
if name == "" {
return nil, fmt.Errorf("tag name cannot be empty")
}
tag, err := s.tagRepo.GetByID(id)
if err != nil {
return nil, err
}
tag.Name = name
tag.Color = color
err = s.tagRepo.Update(tag)
if err != nil {
return nil, err
}
return tag, nil
}
func (s *TagService) DeleteTag(id string) error {
return s.tagRepo.Delete(id)
}

View file

@ -1,99 +0,0 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagService_CreateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Space")
color := "#ff0000"
tag, err := svc.CreateTag(space.ID, "Groceries", &color)
require.NoError(t, err)
assert.NotEmpty(t, tag.ID)
assert.Equal(t, "groceries", tag.Name)
assert.Equal(t, &color, tag.Color)
assert.Equal(t, space.ID, tag.SpaceID)
})
}
func TestTagService_CreateTag_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Empty Space")
tag, err := svc.CreateTag(space.ID, "", nil)
assert.Error(t, err)
assert.Nil(t, tag)
})
}
func TestTagService_GetTagsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc List Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
})
}
func TestTagService_UpdateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Name", nil)
newColor := "#00ff00"
updated, err := svc.UpdateTag(tag.ID, "New Name", &newColor)
require.NoError(t, err)
assert.Equal(t, "new name", updated.Name)
assert.Equal(t, &newColor, updated.Color)
})
}
func TestTagService_DeleteTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := svc.DeleteTag(tag.ID)
require.NoError(t, err)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, tags)
})
}
func TestNormalizeTagName(t *testing.T) {
result := NormalizeTagName(" Hello World ")
assert.Equal(t, "hello world", result)
}

View file

@ -32,11 +32,10 @@ func TestConfig() *config.Config {
}
}
// AuthenticatedContext returns a context with user, profile, config, and CSRF token injected.
func AuthenticatedContext(user *model.User, profile *model.Profile) context.Context {
// AuthenticatedContext returns a context with user, config, and CSRF token injected.
func AuthenticatedContext(user *model.User) context.Context {
ctx := context.Background()
ctx = ctxkeys.WithUser(ctx, user)
ctx = ctxkeys.WithProfile(ctx, profile)
ctx = ctxkeys.WithConfig(ctx, TestConfig().Sanitized())
ctx = ctxkeys.WithCSRFToken(ctx, "test-csrf-token")
return ctx
@ -44,7 +43,7 @@ func AuthenticatedContext(user *model.User, profile *model.Profile) context.Cont
// NewAuthenticatedRequest creates an HTTP request with auth context and optional form values.
// CSRF token is automatically added to form values for POST requests.
func NewAuthenticatedRequest(t *testing.T, method, target string, user *model.User, profile *model.Profile, formValues url.Values) *http.Request {
func NewAuthenticatedRequest(t *testing.T, method, target string, user *model.User, formValues url.Values) *http.Request {
t.Helper()
var req *http.Request
@ -61,7 +60,7 @@ func NewAuthenticatedRequest(t *testing.T, method, target string, user *model.Us
req = httptest.NewRequest(method, target, nil)
}
ctx := AuthenticatedContext(user, profile)
ctx := AuthenticatedContext(user)
req = req.WithContext(ctx)
return req

View file

@ -7,21 +7,22 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/shopspring/decimal"
)
// CreateTestUser inserts a user directly into the database.
func CreateTestUser(t *testing.T, db *sqlx.DB, email string, passwordHash *string) *model.User {
t.Helper()
now := time.Now()
user := &model.User{
ID: uuid.NewString(),
Email: email,
PasswordHash: passwordHash,
CreatedAt: time.Now(),
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5)`,
user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt,
`INSERT INTO users (id, email, name, password_hash, email_verified_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt, user.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestUser: %v", err)
@ -29,33 +30,25 @@ func CreateTestUser(t *testing.T, db *sqlx.DB, email string, passwordHash *strin
return user
}
// CreateTestProfile inserts a profile directly into the database.
func CreateTestProfile(t *testing.T, db *sqlx.DB, userID, name string) *model.Profile {
// CreateTestUserWithName inserts a user with a name directly into the database.
func CreateTestUserWithName(t *testing.T, db *sqlx.DB, email string, name *string) *model.User {
t.Helper()
now := time.Now()
profile := &model.Profile{
user := &model.User{
ID: uuid.NewString(),
UserID: userID,
Email: email,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO profiles (id, user_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt,
`INSERT INTO users (id, email, name, password_hash, email_verified_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt, user.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestProfile: %v", err)
t.Fatalf("CreateTestUserWithName: %v", err)
}
return profile
}
// CreateTestUserWithProfile creates both a user and a profile.
func CreateTestUserWithProfile(t *testing.T, db *sqlx.DB, email, name string) (*model.User, *model.Profile) {
t.Helper()
user := CreateTestUser(t, db, email, nil)
profile := CreateTestProfile(t, db, user.ID, name)
return user, profile
return user
}
// CreateTestSpace inserts a space and adds the owner as a member.
@ -86,167 +79,6 @@ func CreateTestSpace(t *testing.T, db *sqlx.DB, ownerID, name string) *model.Spa
return space
}
// CreateTestTag inserts a tag directly into the database.
func CreateTestTag(t *testing.T, db *sqlx.DB, spaceID, name string, color *string) *model.Tag {
t.Helper()
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO tags (id, space_id, name, color, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
tag.ID, tag.SpaceID, tag.Name, tag.Color, tag.CreatedAt, tag.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestTag: %v", err)
}
return tag
}
// CreateTestShoppingList inserts a shopping list directly into the database.
func CreateTestShoppingList(t *testing.T, db *sqlx.DB, spaceID, name string) *model.ShoppingList {
t.Helper()
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO shopping_lists (id, space_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
list.ID, list.SpaceID, list.Name, list.CreatedAt, list.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestShoppingList: %v", err)
}
return list
}
// CreateTestListItem inserts a list item directly into the database.
func CreateTestListItem(t *testing.T, db *sqlx.DB, listID, name, createdBy string) *model.ListItem {
t.Helper()
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: listID,
Name: name,
IsChecked: false,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO list_items (id, list_id, name, is_checked, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
item.ID, item.ListID, item.Name, item.IsChecked, item.CreatedBy, item.CreatedAt, item.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestListItem: %v", err)
}
return item
}
// CreateTestExpense inserts an expense directly into the database.
func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount decimal.Decimal, typ model.ExpenseType) *model.Expense {
t.Helper()
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: spaceID,
CreatedBy: userID,
Description: desc,
Amount: amount,
Type: typ,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount, type, date, payment_method_id, created_at, updated_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0)`,
expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.Amount,
expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestExpense: %v", err)
}
return expense
}
// CreateTestMoneyAccount inserts a money account directly into the database.
func CreateTestMoneyAccount(t *testing.T, db *sqlx.DB, spaceID, name, createdBy string) *model.MoneyAccount {
t.Helper()
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO money_accounts (id, space_id, name, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
account.ID, account.SpaceID, account.Name, account.CreatedBy, account.CreatedAt, account.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestMoneyAccount: %v", err)
}
return account
}
// CreateTestTransfer inserts an account transfer directly into the database.
func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount decimal.Decimal, direction model.TransferDirection, createdBy string) *model.AccountTransfer {
t.Helper()
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: accountID,
Amount: amount,
Direction: direction,
Note: "test transfer",
CreatedBy: createdBy,
CreatedAt: time.Now(),
}
_, err := db.Exec(
`INSERT INTO account_transfers (id, account_id, amount, direction, note, created_by, created_at, amount_cents) VALUES ($1, $2, $3, $4, $5, $6, $7, 0)`,
transfer.ID, transfer.AccountID, transfer.Amount, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
t.Fatalf("CreateTestTransfer: %v", err)
}
return transfer
}
// CreateTestPaymentMethod inserts a payment method directly into the database.
func CreateTestPaymentMethod(t *testing.T, db *sqlx.DB, spaceID, name string, typ model.PaymentMethodType, createdBy string) *model.PaymentMethod {
t.Helper()
lastFour := "1234"
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Type: typ,
LastFour: &lastFour,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO payment_methods (id, space_id, name, type, last_four, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
method.ID, method.SpaceID, method.Name, method.Type, method.LastFour, method.CreatedBy, method.CreatedAt, method.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestPaymentMethod: %v", err)
}
return method
}
// CreateTestToken inserts a token directly into the database.
func CreateTestToken(t *testing.T, db *sqlx.DB, userID, tokenType, tokenString string, expiresAt time.Time) *model.Token {
t.Helper()

View file

@ -1,37 +0,0 @@
package dialogs
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
templ AddTransaction(space *model.Space, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod) {
@dialog.Dialog(dialog.Props{ID: "add-transaction-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Transaction
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Transaction
}
@dialog.Description() {
Add a new expense or top-up to your space.
}
}
@expense.AddExpenseForm(expense.AddExpenseFormProps{
Space: space,
Tags: tags,
ListsWithItems: listsWithItems,
PaymentMethods: methods,
DialogID: "add-transaction-dialog",
RedirectURL: fmt.Sprintf("/app/spaces/%s/expenses?created=true", space.ID),
})
}
}
}

View file

@ -1,367 +0,0 @@
package expense
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox"
"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/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/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
"github.com/shopspring/decimal"
)
type AddExpenseFormProps struct {
Space *model.Space
Tags []*model.Tag
ListsWithItems []model.ListWithUncheckedItems
PaymentMethods []*model.PaymentMethod
DialogID string // which dialog to close on success
RedirectURL string // when set, server returns HX-Redirect instead of inline swap
}
func (p AddExpenseFormProps) formAttrs() templ.Attributes {
attrs := templ.Attributes{
"hx-post": "/app/spaces/" + p.Space.ID + "/expenses",
}
if p.RedirectURL != "" {
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') end"
} else {
attrs["hx-target"] = "#expenses-list-wrapper"
attrs["hx-swap"] = "innerHTML"
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') then reset() me then show #item-selector-section end"
}
return attrs
}
templ AddExpenseForm(props AddExpenseFormProps) {
<form
class="space-y-4"
{ props.formAttrs()... }
>
@csrf.Token()
if props.RedirectURL != "" {
<input type="hidden" name="redirect" value={ props.RedirectURL }/>
}
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "expense-type-expense",
Name: "type",
Value: "expense",
Checked: true,
Attributes: templ.Attributes{
"_": "on click show #item-selector-section",
},
})
<div class="grid gap-2">
@label.Label(label.Props{
For: "expense-type-expense",
}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "expense-type-topup",
Name: "type",
Value: "topup",
Attributes: templ.Attributes{
"_": "on click hide #item-selector-section",
},
})
<div class="grid gap-2">
@label.Label(label.Props{
For: "expense-type-topup",
}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{
For: "description",
}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "description",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{
For: "amount",
}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Date
<div>
@label.Label(label.Props{
For: "date",
}) {
Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "date",
Name: "date",
Clearable: true,
Required: true,
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "new-expense-tags"}) {
Tags (Optional)
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "new-expense-tags",
Name: "tags",
Tags: props.Tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(props.PaymentMethods, nil)
// Shopping list items selector
@ItemSelectorSection(props.ListsWithItems, false)
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-expense-" + exp.ID }}
{{ tagValues := make([]string, len(exp.Tags)) }}
for i, t := range exp.Tags {
{{ tagValues[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
hx-target={ "#expense-" + exp.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-expense-" + exp.ID,
Name: "type",
Value: "expense",
Checked: exp.Type == model.ExpenseTypeExpense,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-topup-" + exp.ID,
Name: "type",
Value: "topup",
Checked: exp.Type == model.ExpenseTypeTopup,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "edit-description-" + exp.ID,
Value: exp.Description,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-amount-" + exp.ID,
Type: "number",
Value: model.FormatDecimal(exp.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Date
<div>
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-date-" + exp.ID,
Name: "date",
Value: exp.Date,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
Tags (Optional)
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-tags-" + exp.ID,
Name: "tags",
Value: tagValues,
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, exp.PaymentMethodID)
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) {
<div
id="item-selector-section"
if oob {
hx-swap-oob="true"
}
>
@label.Label(label.Props{}) {
Link Shopping List Items
}
if len(listsWithItems) == 0 {
<p class="text-sm text-muted-foreground">No unchecked items available.</p>
} else {
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2">
for i, lwi := range listsWithItems {
{{ toggleID := "toggle-list-" + lwi.List.ID }}
{{ itemsID := "items-" + lwi.List.ID }}
<div class="space-y-1">
<div class="flex items-center gap-2">
@checkbox.Checkbox(checkbox.Props{
ID: "select-all-" + lwi.List.ID,
Attributes: templ.Attributes{
"_": "on change repeat for cb in <input[name='item_ids']/> in #" + itemsID + " set cb.checked to my.checked end",
},
})
@button.Button(button.Props{
ID: toggleID,
Variant: button.VariantGhost,
Class: "flex-1 h-auto p-0 justify-start gap-1 text-sm font-medium select-none",
Attributes: templ.Attributes{
"_": "on click toggle .hidden on #" + itemsID + " then toggle .rotate-90 on <svg/> in me",
},
}) {
@icon.ChevronRight(icon.Props{Size: 14})
{ lwi.List.Name }
<span class="text-muted-foreground">
({ strconv.Itoa(len(lwi.Items)) })
</span>
}
</div>
<div id={ itemsID } class="hidden pl-6 space-y-1">
for _, item := range lwi.Items {
<div class="flex items-center gap-2">
@checkbox.Checkbox(checkbox.Props{
ID: "item-cb-" + item.ID,
Name: "item_ids",
Value: item.ID,
})
<label for={ "item-cb-" + item.ID } class="text-sm cursor-pointer select-none">
{ item.Name }
</label>
</div>
}
</div>
</div>
if i < len(listsWithItems) - 1 {
<hr class="border-border"/>
}
}
</div>
// Post-action radio group
<div class="mt-2 space-y-1">
<p class="text-sm text-muted-foreground">After linking items:</p>
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "item-action-check",
Name: "item_action",
Value: "check",
Checked: true,
})
@label.Label(label.Props{For: "item-action-check"}) {
Mark as checked
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "item-action-delete",
Name: "item_action",
Value: "delete",
})
@label.Label(label.Props{For: "item-action-delete"}) {
Delete from list
}
</div>
</div>
</div>
}
</div>
}
templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) {
<div
id="balance-card"
class="border rounded-lg p-4 bg-card text-card-foreground"
if oob {
hx-swap-oob="true"
}
>
<h2 class="text-lg font-semibold">Current Balance</h2>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(balance) }
if allocated.GreaterThan(decimal.Zero) {
<span class="text-base font-normal text-muted-foreground">
({ model.FormatMoney(allocated) } in accounts)
</span>
}
</p>
</div>
}

View file

@ -1,385 +0,0 @@
package moneyaccount
import (
"fmt"
"strconv"
"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/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"
"github.com/shopspring/decimal"
)
templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {
<div
id="accounts-balance-summary"
class="border rounded-lg p-4 bg-card text-card-foreground"
if oob {
hx-swap-oob="true"
}
>
<h2 class="text-lg font-semibold mb-2">Balance Summary</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<p class="text-sm text-muted-foreground">Total Balance</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(totalBalance) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Allocated</p>
<p class="text-xl font-bold">
{ model.FormatMoney(totalBalance.Sub(availableBalance)) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Available</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(availableBalance) }
</p>
</div>
</div>
</div>
}
templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bool) {
{{ editDialogID := "edit-account-" + acct.ID }}
{{ delDialogID := "del-account-" + acct.ID }}
{{ depositDialogID := "deposit-" + acct.ID }}
{{ withdrawDialogID := "withdraw-" + acct.ID }}
<div
id={ "account-card-" + acct.ID }
class="border rounded-lg p-4 bg-card text-card-foreground"
if len(oob) > 0 && oob[0] {
hx-swap-oob="true"
}
>
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold text-lg">{ acct.Name }</h3>
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(acct.Balance) }
</p>
</div>
<div class="flex gap-1">
// 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 Account
}
@dialog.Description() {
Update the account name.
}
}
@EditAccountForm(spaceID, &acct.MoneyAccount, 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 Account
}
@dialog.Description() {
Are you sure you want to delete "{ acct.Name }"? All transfers will be removed.
}
}
@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/%s", spaceID, acct.ID),
"hx-target": "#account-card-" + acct.ID,
"hx-swap": "delete",
},
}) {
Delete
}
}
}
}
</div>
</div>
<div class="flex gap-2">
// Deposit
@dialog.Dialog(dialog.Props{ID: depositDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
@icon.ArrowDownToLine(icon.Props{Size: 14})
Deposit
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Deposit to { acct.Name }
}
@dialog.Description() {
Move money from your available balance into this account.
}
}
@TransferForm(spaceID, acct.ID, model.TransferDirectionDeposit, depositDialogID)
}
}
// Withdraw
@dialog.Dialog(dialog.Props{ID: withdrawDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
@icon.ArrowUpFromLine(icon.Props{Size: 14})
Withdraw
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Withdraw from { acct.Name }
}
@dialog.Description() {
Move money from this account back to your available balance.
}
}
@TransferForm(spaceID, acct.ID, model.TransferDirectionWithdrawal, withdrawDialogID)
}
}
</div>
</div>
}
templ CreateAccountForm(spaceID string, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/accounts" }
hx-target="#accounts-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()
<div>
@label.Label(label.Props{For: "account-name"}) {
Account Name
}
@input.Input(input.Props{
Name: "name",
ID: "account-name",
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Savings, Emergency Fund"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Create
}
</div>
</form>
}
templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string) {
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID) }
hx-target={ "#account-card-" + acct.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()
<div>
@label.Label(label.Props{For: "edit-account-name-" + acct.ID}) {
Account Name
}
@input.Input(input.Props{
Name: "name",
ID: "edit-account-name-" + acct.ID,
Value: acct.Name,
Attributes: templ.Attributes{"required": "true"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ TransferForm(spaceID string, accountID string, direction model.TransferDirection, dialogID string) {
{{ errorID := "transfer-error-" + accountID + "-" + string(direction) }}
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers", spaceID, accountID) }
hx-target={ "#" + errorID }
hx-swap="innerHTML"
_={ "on transferSuccess from body call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
<input type="hidden" name="direction" value={ string(direction) }/>
<div>
@label.Label(label.Props{For: "transfer-amount-" + accountID + "-" + string(direction)}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "transfer-amount-" + accountID + "-" + string(direction),
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
})
<p id={ errorID } class="text-sm text-destructive mt-1"></p>
</div>
<div>
@label.Label(label.Props{For: "transfer-note-" + accountID + "-" + string(direction)}) {
Note (optional)
}
@input.Input(input.Props{
Name: "note",
ID: "transfer-note-" + accountID + "-" + string(direction),
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
if direction == model.TransferDirectionDeposit {
Deposit
} else {
Withdraw
}
}
</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>
<div class="border rounded-lg">
<div id="transfer-history-wrapper">
@TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false)
</div>
</div>
</div>
}
templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) {
<div
if oob {
id="transfer-history-wrapper"
hx-swap-oob="innerHTML:#transfer-history-wrapper"
}
>
<div class="divide-y">
if len(transfers) == 0 {
<p class="p-4 text-sm text-muted-foreground">No transfers recorded yet.</p>
}
for _, t := range transfers {
@TransferHistoryItem(spaceID, t)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage-1),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, pg),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage+1),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
</div>
}
templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
<div id={ "transfer-" + t.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium">
if t.Note != "" {
{ t.Note }
} else if t.Direction == model.TransferDirectionDeposit {
Deposit
} else {
Withdrawal
}
</p>
</div>
<p class="text-sm text-muted-foreground">
{ t.CreatedAt.Format("Jan 2, 2006") } &middot; { t.AccountName }
</p>
</div>
<div class="flex items-center gap-2">
if t.Direction == model.TransferDirectionDeposit {
<span class="font-bold text-green-600 whitespace-nowrap">
+{ model.FormatMoney(t.Amount) }
</span>
} else {
<span class="font-bold text-destructive whitespace-nowrap">
-{ model.FormatMoney(t.Amount) }
</span>
}
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers/%s", spaceID, t.AccountID, t.ID),
"hx-target": "#transfer-" + t.ID,
"hx-swap": "delete",
"hx-confirm": "Delete this transfer?",
},
}) {
@icon.Trash2(icon.Props{Size: 14})
}
</div>
</div>
}

View file

@ -1,269 +0,0 @@
package paymentmethod
import (
"fmt"
"strings"
"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/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/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
)
func methodDisplay(m *model.PaymentMethod) string {
upper := strings.ToUpper(string(m.Type))
if m.LastFour != nil {
return upper + " **** " + *m.LastFour
}
return upper
}
templ MethodItem(spaceID string, method *model.PaymentMethod) {
{{ editDialogID := "edit-method-" + method.ID }}
{{ delDialogID := "del-method-" + method.ID }}
<div id={ "method-item-" + method.ID } class="border rounded-lg p-4 bg-card text-card-foreground">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-lg">{ method.Name }</h3>
<p class="text-sm text-muted-foreground">
{ methodDisplay(method) }
</p>
</div>
<div class="flex gap-1">
@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 Payment Method
}
@dialog.Description() {
Update the payment method details.
}
}
@EditMethodForm(spaceID, method, editDialogID)
}
}
@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 Payment Method
}
@dialog.Description() {
Are you sure you want to delete "{ method.Name }"? Existing expenses will keep their data but will no longer show a payment method.
}
}
@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/payment-methods/%s", spaceID, method.ID),
"hx-target": "#method-item-" + method.ID,
"hx-swap": "delete",
},
}) {
Delete
}
}
}
}
</div>
</div>
</div>
}
templ CreateMethodForm(spaceID string, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/payment-methods" }
hx-target="#methods-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()
<div>
@label.Label(label.Props{For: "method-name"}) {
Name
}
@input.Input(input.Props{
Name: "name",
ID: "method-name",
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Chase Sapphire"},
})
</div>
<div>
@label.Label(label.Props{}) {
Type
}
<div class="flex gap-4 mt-1">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "method-type-credit",
Name: "type",
Value: "credit",
Checked: true,
})
@label.Label(label.Props{For: "method-type-credit"}) {
Credit
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "method-type-debit",
Name: "type",
Value: "debit",
})
@label.Label(label.Props{For: "method-type-debit"}) {
Debit
}
</div>
</div>
</div>
<div id="last-four-group">
@label.Label(label.Props{For: "method-last-four"}) {
Last 4 Digits
}
@input.Input(input.Props{
Name: "last_four",
ID: "method-last-four",
Attributes: templ.Attributes{
"required": "true",
"maxlength": "4",
"minlength": "4",
"pattern": "[0-9]{4}",
"placeholder": "1234",
},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Create
}
</div>
</form>
}
templ EditMethodForm(spaceID string, method *model.PaymentMethod, dialogID string) {
{{ lastFourVal := "" }}
if method.LastFour != nil {
{{ lastFourVal = *method.LastFour }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/payment-methods/%s", spaceID, method.ID) }
hx-target={ "#method-item-" + method.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()
<div>
@label.Label(label.Props{For: "edit-method-name-" + method.ID}) {
Name
}
@input.Input(input.Props{
Name: "name",
ID: "edit-method-name-" + method.ID,
Value: method.Name,
Attributes: templ.Attributes{"required": "true"},
})
</div>
<div>
@label.Label(label.Props{}) {
Type
}
<div class="flex gap-4 mt-1">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-method-type-credit-" + method.ID,
Name: "type",
Value: "credit",
Checked: method.Type == model.PaymentMethodTypeCredit,
})
@label.Label(label.Props{For: "edit-method-type-credit-" + method.ID}) {
Credit
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-method-type-debit-" + method.ID,
Name: "type",
Value: "debit",
Checked: method.Type == model.PaymentMethodTypeDebit,
})
@label.Label(label.Props{For: "edit-method-type-debit-" + method.ID}) {
Debit
}
</div>
</div>
</div>
<div id={ "edit-last-four-group-" + method.ID }>
@label.Label(label.Props{For: "edit-method-last-four-" + method.ID}) {
Last 4 Digits
}
@input.Input(input.Props{
Name: "last_four",
ID: "edit-method-last-four-" + method.ID,
Value: lastFourVal,
Attributes: templ.Attributes{
"required": "true",
"maxlength": "4",
"minlength": "4",
"pattern": "[0-9]{4}",
},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ MethodSelector(methods []*model.PaymentMethod, selectedMethodID *string) {
<div>
@label.Label(label.Props{}) {
Payment Method
}
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{Name: "payment_method_id"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Cash"})
}
@selectbox.Content(selectbox.ContentProps{NoSearch: len(methods) <= 5}) {
@selectbox.Item(selectbox.ItemProps{Value: "", Selected: selectedMethodID == nil}) {
Cash
}
for _, m := range methods {
if m.LastFour != nil {
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
{ m.Name } (*{ *m.LastFour })
}
} else {
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
{ m.Name }
}
}
}
}
}
</div>
}

View file

@ -1,441 +0,0 @@
package recurring
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"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/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
)
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 RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ delDialogID := "del-recurring-" + re.ID }}
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="font-medium">{ re.Description }</p>
if !re.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
{ frequencyLabel(re.Frequency) }
}
<span>Next: { re.NextOccurrence.Format("Jan 02, 2006") }</span>
if re.PaymentMethod != nil {
if re.PaymentMethod.LastFour != nil {
<span>&middot; { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
} else {
<span>&middot; { re.PaymentMethod.Name }</span>
}
}
</div>
if len(re.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range re.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<div class="flex items-center gap-1 shrink-0">
if re.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { model.FormatMoney(re.Amount) }
</p>
} else {
<p class="font-bold text-green-500">
+ { model.FormatMoney(re.Amount) }
</p>
}
// Toggle pause/resume
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7",
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/recurring/%s/toggle", spaceID, re.ID),
"hx-target": "#recurring-" + re.ID,
"hx-swap": "outerHTML",
},
}) {
if re.IsActive {
@icon.Pause(icon.Props{Size: 14})
} else {
@icon.Play(icon.Props{Size: 14})
}
}
// Edit button
@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 Transaction
}
@dialog.Description() {
Update the details of this recurring transaction.
}
}
@EditRecurringForm(spaceID, re, methods, tags)
}
}
// Delete button
@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 Transaction
}
@dialog.Description() {
Are you sure you want to delete "{ re.Description }"? This will not remove previously generated expenses.
}
}
@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/recurring/%s", spaceID, re.ID),
"hx-target": "#recurring-" + re.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.PaymentMethod, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/recurring" }
hx-target="#recurring-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()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "recurring-type-expense",
Name: "type",
Value: "expense",
Checked: true,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "recurring-type-expense"}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "recurring-type-topup",
Name: "type",
Value: "topup",
})
<div class="grid gap-2">
@label.Label(label.Props{For: "recurring-type-topup"}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "recurring-description"}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "recurring-description",
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "recurring-amount"}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "recurring-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{}) {
Frequency
}
@selectbox.SelectBox(selectbox.Props{ID: "recurring-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: "recurring-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "recurring-start-date",
Name: "start_date",
Required: true,
Clearable: true,
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "recurring-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "recurring-end-date",
Name: "end_date",
Clearable: true,
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "recurring-tags"}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "recurring-tags",
Name: "tags",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, nil)
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ tagValues := make([]string, len(re.Tags)) }}
for i, t := range re.Tags {
{{ tagValues[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID) }
hx-target={ "#recurring-" + re.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-recurring-type-expense-" + re.ID,
Name: "type",
Value: "expense",
Checked: re.Type == model.ExpenseTypeExpense,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-recurring-type-expense-" + re.ID}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-recurring-type-topup-" + re.ID,
Name: "type",
Value: "topup",
Checked: re.Type == model.ExpenseTypeTopup,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-recurring-type-topup-" + re.ID}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "edit-recurring-desc-" + re.ID}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "edit-recurring-desc-" + re.ID,
Value: re.Description,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-recurring-amount-" + re.ID}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-recurring-amount-" + re.ID,
Type: "number",
Value: model.FormatDecimal(re.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{}) {
Frequency
}
@selectbox.SelectBox(selectbox.Props{ID: "edit-recurring-freq-" + re.ID}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
@selectbox.Value()
}
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
@selectbox.Item(selectbox.ItemProps{Value: "daily", Selected: re.Frequency == model.FrequencyDaily}) {
Daily
}
@selectbox.Item(selectbox.ItemProps{Value: "weekly", Selected: re.Frequency == model.FrequencyWeekly}) {
Weekly
}
@selectbox.Item(selectbox.ItemProps{Value: "biweekly", Selected: re.Frequency == model.FrequencyBiweekly}) {
Biweekly
}
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: re.Frequency == model.FrequencyMonthly}) {
Monthly
}
@selectbox.Item(selectbox.ItemProps{Value: "yearly", Selected: re.Frequency == model.FrequencyYearly}) {
Yearly
}
}
}
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-recurring-start-" + re.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-start-" + re.ID,
Name: "start_date",
Value: re.StartDate,
Required: true,
Clearable: true,
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "edit-recurring-end-" + re.ID}) {
End Date (optional)
}
if re.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-end-" + re.ID,
Name: "end_date",
Value: *re.EndDate,
Clearable: true,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-end-" + re.ID,
Name: "end_date",
Clearable: true,
})
}
</div>
// Tags
<div>
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-recurring-tags-" + re.ID,
Name: "tags",
Value: tagValues,
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}

View file

@ -1,300 +0,0 @@
package shoppinglist
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"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/pagination"
)
// ListCard renders a full shopping list card with inline items, add form, and pagination.
templ ListCard(spaceID string, list *model.ShoppingList, items []*model.ListItem, currentPage, totalPages int) {
<div id={ "list-card-" + list.ID } class="border rounded-lg overflow-hidden flex flex-col">
<div class="p-4 border-b bg-muted/30">
@ListCardHeader(spaceID, list)
</div>
<div class="p-3 border-b">
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/lists/%s/items", spaceID, list.ID) }
hx-swap="none"
_={ fmt.Sprintf("on htmx:afterRequest if event.detail.successful reset() me then send refreshItems to #list-items-%s", list.ID) }
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "Add item...",
Class: "h-8 text-sm",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Submit(button.Props{
Size: button.SizeSm,
}) {
@icon.Plus(icon.Props{Size: 16})
}
</form>
</div>
<div
id={ "list-items-" + list.ID }
hx-get={ fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=1", spaceID, list.ID) }
hx-trigger="refreshItems"
hx-swap="innerHTML"
>
@ListCardItems(spaceID, list.ID, items, currentPage, totalPages)
</div>
</div>
}
// ListCardHeader renders the card header with name display, edit form, and delete button.
templ ListCardHeader(spaceID string, list *model.ShoppingList) {
<div id={ "lch-" + list.ID } class="flex items-center justify-between gap-2">
<h3 class="font-semibold truncate">{ list.Name }</h3>
<div class="flex items-center gap-1 shrink-0">
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7 text-muted-foreground hover:text-foreground",
Attributes: templ.Attributes{
"_": fmt.Sprintf("on click toggle .hidden on #lch-%s then toggle .hidden on #lche-%s then focus() the first <input/> in #lche-%s", list.ID, list.ID, list.ID),
},
}) {
@icon.Pencil(icon.Props{Size: 14})
}
@dialog.Dialog(dialog.Props{ID: "del-list-" + list.ID}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7 text-muted-foreground hover:text-destructive",
}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Shopping List
}
@dialog.Description() {
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items.
}
}
@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/lists/%s?from=card", spaceID, list.ID),
"hx-target": "#list-card-" + list.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
<form
id={ "lche-" + list.ID }
class="hidden flex items-center gap-2"
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s?from=card", spaceID, list.ID) }
hx-target={ "#lch-" + list.ID }
hx-swap="outerHTML"
_={ fmt.Sprintf("on htmx:afterRequest toggle .hidden on me then toggle .hidden on #lch-%s", list.ID) }
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: list.Name,
Class: "h-8 text-sm",
Attributes: templ.Attributes{
"required": "true",
},
})
@button.Submit(button.Props{Size: button.SizeSm}) {
Save
}
@button.Button(button.Props{
Variant: button.VariantOutline,
Size: button.SizeSm,
Attributes: templ.Attributes{
"_": fmt.Sprintf("on click toggle .hidden on #lche-%s then toggle .hidden on #lch-%s", list.ID, list.ID),
},
}) {
Cancel
}
</form>
}
// ListCardItems renders the paginated items section within a card.
templ ListCardItems(spaceID string, listID string, items []*model.ListItem, currentPage, totalPages int) {
if len(items) == 0 {
<p class="text-center text-muted-foreground p-6 text-sm">No items yet</p>
} else {
<div class="divide-y">
for _, item := range items {
@CardItemDetail(spaceID, item)
}
</div>
}
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage-1),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, pg),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage+1),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
// CardItemDetail renders an item within a card. Toggle is in-place, delete triggers a refresh.
templ CardItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 px-4 py-2">
@checkbox.Checkbox(checkbox.Props{
ID: "item-" + item.ID + "-checkbox",
Name: "is_checked",
Checked: item.IsChecked,
Attributes: templ.Attributes{
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s?from=card", spaceID, item.ListID, item.ID),
"hx-target": "#item-" + item.ID,
"hx-swap": "outerHTML",
},
})
<span class={ "text-sm flex-1", templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7 text-muted-foreground hover:text-destructive shrink-0",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
"hx-swap": "none",
"_": fmt.Sprintf("on htmx:afterRequest send refreshItems to #list-items-%s", item.ListID),
},
}) {
@icon.X(icon.Props{Size: 14})
}
</div>
}
// ListNameHeader is used on the detail page for editing list name inline.
templ ListNameHeader(spaceID string, list *model.ShoppingList) {
<div id="list-name-header" class="flex items-center gap-2 group">
<h1 class="text-2xl font-bold">{ list.Name }</h1>
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity",
Attributes: templ.Attributes{
"_": "on click toggle .hidden on #list-name-header then toggle .hidden on #list-name-edit then focus() the first <input/> in #list-name-edit",
},
}) {
@icon.Pencil(icon.Props{Size: 16})
}
</div>
<form
id="list-name-edit"
class="hidden flex items-center gap-2"
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s", spaceID, list.ID) }
hx-target="#list-name-header"
hx-swap="outerHTML"
_="on htmx:afterRequest toggle .hidden on me then toggle .hidden on #list-name-header"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: list.Name,
Class: "max-w-xs",
Attributes: templ.Attributes{
"required": "true",
},
})
@button.Submit() {
Save
}
@button.Button(button.Props{
Variant: button.VariantOutline,
Attributes: templ.Attributes{
"_": "on click toggle .hidden on #list-name-edit then toggle .hidden on #list-name-header",
},
}) {
Cancel
}
</form>
}
// ItemDetail renders an individual item row (used by the detail page and toggle responses).
templ ItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
@checkbox.Checkbox(checkbox.Props{
ID: "item-" + item.ID + "-checkbox",
Name: "is_checked",
Checked: item.IsChecked,
Attributes: templ.Attributes{
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
"hx-target": "#item-" + item.ID,
"hx-swap": "outerHTML",
},
})
<span class={ templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "ml-auto size-7",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
"hx-target": "#item-" + item.ID,
"hx-swap": "outerHTML",
},
}) {
@icon.X(icon.Props{Size: 14})
}
</div>
}

View file

@ -1,23 +0,0 @@
package tag
import "git.juancwu.dev/juancwu/budgit/internal/model"
templ Tag(tag *model.Tag) {
<div
id={ "tag-" + tag.ID }
class="flex items-center gap-2 rounded-full border px-3 py-1 text-sm"
>
if tag.Color != nil {
<span class="size-3 rounded-full" style={ "background-color: " + *tag.Color }></span>
}
<span>{ tag.Name }</span>
<button
hx-delete={ "/app/spaces/" + tag.SpaceID + "/tags/" + tag.ID }
hx-target={ "#tag-" + tag.ID }
hx-swap="outerHTML"
class="ml-auto text-muted-foreground hover:text-destructive"
>
&times;
</button>
</div>
}

View file

@ -1,158 +0,0 @@
package tagcombobox
import (
"strings"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/utils"
)
type Props struct {
ID string
Name string // form field name (default "tags")
Value []string // pre-selected tag names
Tags []*model.Tag // all available tags in the space
Placeholder string
HasError bool
Disabled bool
Form string // associate with external form
}
func (p Props) fieldName() string {
if p.Name != "" {
return p.Name
}
return "tags"
}
func (p Props) isSelected(tagName string) bool {
lower := strings.ToLower(tagName)
for _, v := range p.Value {
if strings.ToLower(v) == lower {
return true
}
}
return false
}
templ TagCombobox(props Props) {
<div
id={ props.ID + "-container" }
class={
utils.TwMerge(
"relative w-full",
),
}
data-tagcombobox
data-tagcombobox-name={ props.fieldName() }
data-tagcombobox-form={ props.Form }
>
// Main input area styled like tagsinput
<div
class={
utils.TwMerge(
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none cursor-text",
"dark:bg-input/30",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
utils.If(props.Disabled, "opacity-50 cursor-not-allowed"),
"w-full min-h-[38px]",
utils.If(props.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
),
}
data-tagcombobox-input-area
>
// Selected tag chips
<div class="flex items-center flex-wrap gap-2" data-tagcombobox-chips>
for _, val := range props.Value {
@badge.Badge(badge.Props{
Attributes: templ.Attributes{"data-tagcombobox-chip": val},
}) {
<span>{ val }</span>
<button
type="button"
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
disabled?={ props.Disabled }
data-tagcombobox-remove={ val }
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
}
}
</div>
// Text input for searching/typing
<input
type="text"
id={ props.ID }
class="border-0 shadow-none focus-visible:ring-0 focus-visible:outline-none h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent flex-1 min-w-[80px] text-sm"
placeholder={ props.Placeholder }
disabled?={ props.Disabled }
autocomplete="off"
data-tagcombobox-text-input
/>
</div>
// Dropdown
<div
class="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md hidden max-h-60 overflow-y-auto"
data-tagcombobox-dropdown
>
for _, tag := range props.Tags {
<div
class={
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
templ.KV("font-medium", props.isSelected(tag.Name)),
}
data-tagcombobox-item={ tag.Name }
>
<svg
xmlns="http://www.w3.org/2000/svg"
class={
"h-4 w-4 shrink-0",
templ.KV("opacity-100", props.isSelected(tag.Name)),
templ.KV("opacity-0", !props.isSelected(tag.Name)),
}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
data-tagcombobox-check
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
if tag.Color != nil {
<span class="inline-block w-3 h-3 rounded-full shrink-0" style={ "background-color: " + *tag.Color }></span>
}
<span data-tagcombobox-item-label>{ tag.Name }</span>
</div>
}
// "Create new" option (hidden by default, shown when typing something new)
<div
class="hidden items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground text-muted-foreground"
data-tagcombobox-create
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
<span>Create "<span data-tagcombobox-create-label></span>"</span>
</div>
</div>
// Hidden inputs for form submission
<div data-tagcombobox-hidden-inputs>
for _, val := range props.Value {
<input
type="hidden"
name={ props.fieldName() }
value={ val }
if props.Form != "" {
form={ props.Form }
}
/>
}
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/tagcombobox.js") }></script>
}

View file

@ -75,9 +75,8 @@ templ App(title string) {
@sidebar.Menu() {
@sidebar.MenuItem() {
{{ user := ctxkeys.User(ctx) }}
{{ profile := ctxkeys.Profile(ctx) }}
if user != nil && profile != nil {
@AppSidebarDropdown(user, profile)
if user != nil {
@AppSidebarDropdown(user)
}
}
}
@ -113,7 +112,9 @@ templ App(title string) {
}
}
templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
templ AppSidebarDropdown(user *model.User) {
{{ displayName := user.Email }}
{{ if user.Name != nil && *user.Name != "" { displayName = *user.Name } }}
@dropdown.Dropdown() {
@dropdown.Trigger() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
@ -121,16 +122,13 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
}) {
<div id="sidebar-avatar" hx-swap-oob="true">
@avatar.Avatar(avatar.Props{Class: "size-8 rounded-lg"}) {
<!-- if user.AvatarURL != "" { -->
<!-- @avatar.Image(avatar.ImageProps{Src: user.AvatarURL, Alt: profile.Name}) -->
<!-- } -->
@avatar.Fallback() {
{ strings.ToUpper(string(profile.Name[0])) }
{ strings.ToUpper(string(displayName[0])) }
}
}
</div>
<div id="sidebar-user-name" hx-swap-oob="true" class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{ profile.Name }</span>
<span class="truncate font-medium">{ displayName }</span>
<span class="truncate text-xs text-muted-foreground">{ user.Email }</span>
</div>
@icon.ChevronsUpDown(icon.Props{Class: "ml-auto size-4"})
@ -143,7 +141,7 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
<div id="dropdown-user-label" hx-swap-oob="true">
@dropdown.Label() {
<div class="flex flex-col">
<span class="font-medium">{ profile.Name }</span>
<span class="font-medium">{ displayName }</span>
<span class="text-xs text-muted-foreground">{ user.Email }</span>
</div>
}

View file

@ -13,7 +13,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
import "fmt"
import "time"
@ -54,7 +54,7 @@ templ Base(props ...SEOProps) {
@progress.Script()
@tagsinput.Script()
@selectbox.Script()
@tagcombobox.Script()
// Site-wide enhancements
@themeScript()
// Must run before body to prevent flash

View file

@ -1,226 +0,0 @@
package layouts
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
"strings"
)
templ Space(title string, space *model.Space) {
{{ cfg := ctxkeys.Config(ctx) }}
@Base(SEOProps{
Title: title,
Description: "Space Dashboard",
Path: ctxkeys.URLPath(ctx),
}) {
@sidebar.Layout() {
@sidebar.Sidebar() {
@sidebar.Header() {
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Size: sidebar.MenuButtonSizeLg,
Href: "/app/spaces",
}) {
@icon.LayoutDashboard()
<div class="flex flex-col">
<span class="text-sm font-bold">{ cfg.AppName }</span>
<span class="text-xs text-muted-foreground">Back to Spaces</span>
</div>
}
}
}
}
@sidebar.Content() {
@sidebar.Group() {
@sidebar.GroupLabel() {
{ space.Name }
}
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID,
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
Tooltip: "Overview",
}) {
@icon.House(icon.Props{Class: "size-4"})
<span>Overview</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/reports",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/reports",
Tooltip: "Reports",
}) {
@icon.ChartPie(icon.Props{Class: "size-4"})
<span>Reports</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/expenses",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/expenses",
Tooltip: "Expenses",
}) {
@icon.DollarSign(icon.Props{Class: "size-4"})
<span>Expenses</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/recurring",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/recurring",
Tooltip: "Recurring Transactions",
}) {
@icon.Repeat(icon.Props{Class: "size-4"})
<span>Recurring</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/budgets",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/budgets",
Tooltip: "Budgets",
}) {
@icon.Target(icon.Props{Class: "size-4"})
<span>Budgets</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/loans",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/loans" || strings.HasPrefix(ctxkeys.URLPath(ctx), "/app/spaces/"+space.ID+"/loans/"),
Tooltip: "Loans",
}) {
@icon.Landmark(icon.Props{Class: "size-4"})
<span>Loans</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/accounts",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/accounts",
Tooltip: "Money Accounts",
}) {
@icon.PiggyBank(icon.Props{Class: "size-4"})
<span>Accounts</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/payment-methods",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/payment-methods",
Tooltip: "Payment Methods",
}) {
@icon.CreditCard(icon.Props{Class: "size-4"})
<span>Payment Methods</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/lists",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/lists",
Tooltip: "Shopping Lists",
}) {
@icon.ShoppingCart(icon.Props{Class: "size-4"})
<span>Shopping Lists</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/tags",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/tags",
Tooltip: "Tags",
}) {
@icon.Tag(icon.Props{Class: "size-4"})
<span>Tags</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/settings",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings",
Tooltip: "Settings",
}) {
@icon.Settings(icon.Props{Class: "size-4"})
<span>Settings</span>
}
}
}
}
}
@sidebar.Footer() {
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "mailto:" + cfg.SupportEmail,
Size: sidebar.MenuButtonSizeSm,
}) {
@icon.MessageCircleQuestionMark(icon.Props{Class: "size-4"})
<span>Support</span>
}
}
}
<div class="px-2 py-1">
<span class="text-xs text-muted-foreground">App version: { cfg.Version }</span>
</div>
@sidebar.Separator()
@sidebar.Menu() {
@sidebar.MenuItem() {
{{ user := ctxkeys.User(ctx) }}
{{ profile := ctxkeys.Profile(ctx) }}
if user != nil && profile != nil {
@AppSidebarDropdown(user, profile)
}
}
}
}
}
@sidebar.Inset() {
<div class="flex flex-col h-full">
// Top Navigation Bar
<header class="sticky top-0 z-10 border-b bg-background">
<div class="flex h-14 items-center px-6">
<div class="flex items-center gap-4">
@sidebar.Trigger()
@breadcrumb.Breadcrumb() {
@breadcrumb.List() {
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces"}) {
Spaces
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
{ space.Name }
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Page() {
{ title }
}
}
}
}
</div>
<div class="ml-auto flex items-center gap-4">
@blocks.ThemeSwitcher()
</div>
</div>
</header>
// App Content
<main class="flex-1 p-6">
{ children... }
</main>
</div>
}
}
}
}

View file

@ -1,94 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"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"
)
templ Dashboard(spaces []*model.Space) {
@layouts.App("Spaces") {
<div class="container max-w-7xl px-6 py-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold">Spaces</h1>
<p class="text-muted-foreground mt-2">
Welcome back!
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
for _, space := range spaces {
<a href={ templ.SafeURL("/app/spaces/" + space.ID) } class="block hover:no-underline group">
@card.Card(card.Props{Class: "h-full transition-colors group-hover:border-primary"}) {
@card.Header() {
@card.Title() {
{ space.Name }
}
@card.Description() {
Manage expenses in this space.
}
}
@card.Content()
}
</a>
}
// Option to create a new space
@dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) {
@dialog.Trigger() {
@card.Card(card.Props{Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary"}) {
@card.Content(card.ContentProps{Class: "h-full flex flex-col items-center justify-center py-12"}) {
@icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"})
<p class="text-muted-foreground">Create a new space</p>
}
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Create Space
}
@dialog.Description() {
Create a new space to organize expenses and more.
}
}
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "space-name"}) {
Name
}
@input.Input(input.Props{
ID: "space-name",
Name: "name",
Type: input.TypeText,
Placeholder: "e.g. Household, Trip, Roommates",
Attributes: templ.Attributes{
"describedby": "create-space-error",
},
})
<p id="create-space-error" class="text-sm text-destructive"></p>
</div>
@dialog.Footer() {
@dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) {
@button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) {
Cancel
}
}
@button.Submit() {
Create
}
}
</form>
}
}
</div>
</div>
}
}

View file

@ -1,7 +1,6 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
@ -9,55 +8,15 @@ import (
"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/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
)
templ AppSettings(hasPassword bool, errorMsg string, currentTimezone string) {
templ AppSettings(hasPassword bool, errorMsg string) {
@layouts.App("Settings") {
<div class="container max-w-2xl px-6 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">Settings</h1>
<p class="text-muted-foreground mt-2">Manage your account settings</p>
</div>
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
Set your timezone for recurring expenses and reports
}
}
@card.Content() {
<form action="/app/settings/timezone" method="POST" class="space-y-4">
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: tz.Value == currentTimezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Update Timezone
}
</form>
}
}
<div class="mt-6"></div>
@card.Card() {
@card.Header() {
@card.Title() {

View file

@ -1,48 +0,0 @@
package pages
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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
)
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance decimal.Decimal, availableBalance decimal.Decimal, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
@layouts.Space("Accounts", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Money Accounts</h1>
@dialog.Dialog(dialog.Props{ID: "add-account-dialog"}) {
@dialog.Trigger() {
@button.Button() {
New Account
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Create Account
}
@dialog.Description() {
Create a new money account to set aside funds.
}
}
@moneyaccount.CreateAccountForm(space.ID, "add-account-dialog")
}
}
</div>
@moneyaccount.BalanceSummaryCard(space.ID, totalBalance, availableBalance, false)
<div id="accounts-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(accounts) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No money accounts yet. Create one to start allocating funds.</p>
}
for _, acct := range accounts {
@moneyaccount.AccountCard(space.ID, &acct)
}
</div>
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
</div>
}
}

View file

@ -1,398 +0,0 @@
package pages
import (
"fmt"
"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/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
func periodLabel(p model.BudgetPeriod) string {
switch p {
case model.BudgetPeriodWeekly:
return "Weekly"
case model.BudgetPeriodYearly:
return "Yearly"
default:
return "Monthly"
}
}
func progressBarColor(status model.BudgetStatus) string {
switch status {
case model.BudgetStatusOver:
return "bg-destructive"
case model.BudgetStatusWarning:
return "bg-yellow-500"
default:
return "bg-green-500"
}
}
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
@layouts.Space("Budgets", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Budgets</h1>
@dialog.Dialog(dialog.Props{ID: "add-budget-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Budget
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Budget
}
@dialog.Description() {
Set a spending limit for one or more tag categories.
}
}
@AddBudgetForm(space.ID, tags)
}
}
</div>
<div id="budgets-list-wrapper">
@BudgetsList(space.ID, budgets, tags)
</div>
</div>
}
}
templ BudgetsList(spaceID string, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
<div id="budgets-list" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
if len(budgets) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No budgets set up yet.</p>
}
for _, b := range budgets {
@BudgetCard(spaceID, b, tags)
}
</div>
}
templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ delDialogID := "del-budget-" + b.ID }}
{{ pct := b.Percentage }}
if pct > 100 {
{{ pct = 100 }}
}
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2 flex-wrap">
for _, t := range b.Tags {
<span class="inline-flex items-center gap-1">
if t.Color != nil {
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *t.Color }></span>
}
<span class="text-sm font-semibold">{ t.Name }</span>
</span>
}
</div>
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
</div>
<div class="flex gap-1">
@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 Budget
}
@dialog.Description() {
Update this budget's settings.
}
}
@EditBudgetForm(spaceID, b, tags)
}
}
@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 Budget
}
@dialog.Description() {
Are you sure you want to delete this budget?
}
}
@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/budgets/%s", spaceID, b.ID),
"hx-target": "#budget-" + b.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
// Progress bar
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<div class="w-full bg-muted rounded-full h-2.5">
<div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div>
if b.Status == model.BudgetStatusOver {
<p class="text-xs text-destructive font-medium">Over budget by { model.FormatMoney(b.Spent.Sub(b.Amount)) }</p>
}
</div>
</div>
}
templ AddBudgetForm(spaceID string, tags []*model.Tag) {
<form
hx-post={ "/app/spaces/" + spaceID + "/budgets" }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('add-budget-dialog') then reset() me end"
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "budget-tags"}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "budget-tags",
Name: "tags",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "budget-amount"}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "budget-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-monthly",
Name: "period",
Value: "monthly",
Checked: true,
})
@label.Label(label.Props{For: "budget-period-monthly"}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-weekly",
Name: "period",
Value: "weekly",
})
@label.Label(label.Props{For: "budget-period-weekly"}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-yearly",
Name: "period",
Value: "yearly",
})
@label.Label(label.Props{For: "budget-period-yearly"}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "budget-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-start-date",
Name: "start_date",
Required: true,
Clearable: true,
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "budget-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-end-date",
Name: "end_date",
Clearable: true,
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ budgetTagNames := make([]string, len(b.Tags)) }}
for i, t := range b.Tags {
{{ budgetTagNames[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "edit-budget-tags-" + b.ID}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-budget-tags-" + b.ID,
Name: "tags",
Value: budgetTagNames,
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-budget-amount-" + b.ID}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-budget-amount-" + b.ID,
Type: "number",
Value: model.FormatDecimal(b.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-monthly-" + b.ID,
Name: "period",
Value: "monthly",
Checked: b.Period == model.BudgetPeriodMonthly,
})
@label.Label(label.Props{For: "edit-budget-period-monthly-" + b.ID}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-weekly-" + b.ID,
Name: "period",
Value: "weekly",
Checked: b.Period == model.BudgetPeriodWeekly,
})
@label.Label(label.Props{For: "edit-budget-period-weekly-" + b.ID}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-yearly-" + b.ID,
Name: "period",
Value: "yearly",
Checked: b.Period == model.BudgetPeriodYearly,
})
@label.Label(label.Props{For: "edit-budget-period-yearly-" + b.ID}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-budget-start-" + b.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-start-" + b.ID,
Name: "start_date",
Value: b.StartDate,
Clearable: true,
Required: true,
})
</div>
// End Date
<div>
@label.Label(label.Props{For: "edit-budget-end-" + b.ID}) {
End Date (optional)
}
if b.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
Value: *b.EndDate,
Clearable: true,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
Clearable: true,
})
}
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}

View file

@ -1,198 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
)
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) {
@layouts.Space("Expenses", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Expenses</h1>
@dialogs.AddTransaction(space, tags, listsWithItems, methods)
</div>
// Balance Card
@expense.BalanceCard(space.ID, balance, allocated, false)
// List of expenses
<div class="border rounded-lg">
<div id="expenses-list-wrapper">
@ExpensesListContent(space.ID, expenses, methods, tags, currentPage, totalPages)
</div>
</div>
</div>
}
}
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag, currentPage, totalPages int) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, exp := range expenses {
@ExpenseListItem(spaceID, exp, methods, tags)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage-1),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, pg),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage+1),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium">{ exp.Description }</p>
if exp.RecurringExpenseID != nil {
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
}
</div>
<p class="text-sm text-muted-foreground">
{ exp.Date.Format("Jan 02, 2006") }
if exp.PaymentMethod != nil {
if exp.PaymentMethod.LastFour != nil {
<span>&middot; { exp.PaymentMethod.Name } (*{ *exp.PaymentMethod.LastFour })</span>
} else {
<span>&middot; { exp.PaymentMethod.Name }</span>
}
} else {
<span>&middot; Cash</span>
}
</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<div class="flex items-center gap-1 shrink-0">
if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { model.FormatMoney(exp.Amount) }
</p>
} else {
<p class="font-bold text-green-500">
+ { model.FormatMoney(exp.Amount) }
</p>
}
// Edit button
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
@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 Transaction
}
@dialog.Description() {
Update the details of this transaction.
}
}
@expense.EditExpenseForm(spaceID, exp, methods, tags)
}
}
// Delete button
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
@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 Transaction
}
@dialog.Description() {
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
}
}
@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/expenses/%s", spaceID, exp.ID),
"hx-target": "#expense-" + exp.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
@expense.BalanceCard(spaceID, balance, allocated, true)
}
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, methods []*model.PaymentMethod, tags []*model.Tag) {
@ExpenseListItem(spaceID, exp, methods, tags)
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
}

View file

@ -1,88 +0,0 @@
package pages
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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []*model.ListItem) {
@layouts.Space(list.Name, space) {
<div class="space-y-4">
<div class="flex items-center justify-between">
@shoppinglist.ListNameHeader(space.ID, list)
@dialog.Dialog(dialog.Props{ID: "delete-list-dialog"}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost}) {
Delete List
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Shopping List
}
@dialog.Description() {
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items. This action cannot be undone.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + space.ID + "/lists/" + list.ID,
},
}) {
Delete
}
}
}
}
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
hx-target="#items-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New item...",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Submit() {
Add Item
}
</form>
<div
id="items-container"
class="border rounded-lg"
>
@ShoppingListItems(space.ID, items)
</div>
</div>
}
}
templ ShoppingListItems(spaceID string, items []*model.ListItem) {
if len(items) == 0 {
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
} else {
for _, item := range items {
@shoppinglist.ItemDetail(spaceID, item)
}
}
}

View file

@ -1,48 +0,0 @@
package pages
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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
@layouts.Space("Shopping Lists", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Shopping Lists</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists" }
hx-target="#lists-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New list name...",
})
@button.Submit() {
Create
}
</form>
<div
id="lists-container"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
@ListsContainer(space.ID, cards)
</div>
</div>
}
}
templ ListsContainer(spaceID string, cards []model.ListCardData) {
for _, card := range cards {
@shoppinglist.ListCard(spaceID, card.List, card.Items, card.CurrentPage, card.TotalPages)
}
}

View file

@ -1,608 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
@layouts.Space(loan.Name, space) {
<div class="space-y-6">
// Loan Summary Card
@LoanSummaryCard(space.ID, loan)
// Actions
if !loan.IsPaidOff {
<div class="flex gap-2">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
Make Payment
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Make Payment
}
@dialog.Description() {
Record a payment toward { loan.Name }
}
}
@CreateReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm, Variant: button.VariantOutline}) {
@icon.Repeat(icon.Props{Class: "size-4 mr-1"})
Set Up Recurring
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Recurring Payment
}
@dialog.Description() {
Automatically create payments on a schedule
}
}
@CreateRecurringReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
</div>
}
// Recurring Receipts
if len(recurringReceipts) > 0 {
<div class="space-y-2">
<h2 class="text-lg font-semibold">Recurring Payments</h2>
<div class="border rounded-lg divide-y">
for _, rr := range recurringReceipts {
@RecurringReceiptItem(space.ID, loan.ID, rr)
}
</div>
</div>
}
// Receipt History
<div class="space-y-2">
<h2 class="text-lg font-semibold">Payment History</h2>
<div class="border rounded-lg">
<div id="receipts-list-wrapper">
@ReceiptsListContent(space.ID, loan.ID, receipts, currentPage, totalPages)
</div>
</div>
</div>
</div>
}
}
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if !loan.OriginalAmount.IsZero() {
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
@card.Card(card.Props{}) {
@card.Header() {
<div class="flex justify-between items-start">
<div>
@card.Title() {
{ loan.Name }
}
@card.Description() {
if loan.Description != "" {
{ loan.Description }
}
}
</div>
<div class="flex items-center gap-2">
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Loan
}
@dialog.Description() {
Are you sure? This will delete all payment records for this loan. Linked expenses and account transfers will be kept as history.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
@card.Content() {
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-sm text-muted-foreground">Original</p>
<p class="text-lg font-semibold">{ model.FormatMoney(loan.OriginalAmount) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Paid</p>
<p class="text-lg font-semibold text-green-600">{ model.FormatMoney(loan.TotalPaid) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold">
if loan.Remaining.GreaterThan(decimal.Zero) {
{ model.FormatMoney(loan.Remaining) }
} else {
$0.00
}
</p>
</div>
</div>
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-3",
})
<div class="flex justify-between text-sm text-muted-foreground">
<span>{ strconv.Itoa(progressPct) }% paid</span>
if loan.InterestRateBps > 0 {
<span>{ fmt.Sprintf("%.2f%% interest", float64(loan.InterestRateBps)/100.0) }</span>
}
</div>
</div>
}
}
}
templ ReceiptsListContent(spaceID, loanID string, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int) {
<div id="receipts-list" class="divide-y">
if len(receipts) == 0 {
<p class="p-4 text-sm text-muted-foreground">No payments recorded yet.</p>
}
for _, receipt := range receipts {
@ReceiptListItem(spaceID, loanID, receipt)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage-1),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, pg),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage+1),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesAndAccounts) {
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="font-medium">{ model.FormatMoney(receipt.TotalAmount) }</span>
<span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span>
if receipt.RecurringReceiptID != nil {
@icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"})
}
</div>
if receipt.Description != "" {
<p class="text-sm text-muted-foreground">{ receipt.Description }</p>
}
<div class="flex flex-wrap gap-1">
for _, src := range receipt.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ fmt.Sprintf("%s %s", src.AccountName, model.FormatMoney(src.Amount)) }
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Payment
}
@dialog.Description() {
This will also reverse the linked expense and account transfers.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/receipts/%s", spaceID, loanID, receipt.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWithSources) {
<div class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
@icon.Repeat(icon.Props{Class: "size-4"})
<span class="font-medium">{ model.FormatMoney(rr.TotalAmount) }</span>
<span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span>
if !rr.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
if rr.Description != "" {
<p class="text-sm text-muted-foreground">{ rr.Description }</p>
}
<p class="text-xs text-muted-foreground">
Next: { rr.NextOccurrence.Format("Jan 2, 2006") }
</p>
<div class="flex flex-wrap gap-1">
for _, src := range rr.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
if src.AccountID != nil {
{ fmt.Sprintf("Account %s", model.FormatMoney(src.Amount)) }
}
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s/toggle", spaceID, loanID, rr.ID),
},
}) {
if rr.IsActive {
@icon.Pause(icon.Props{Class: "size-4"})
} else {
@icon.Play(icon.Props{Class: "size-4"})
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Recurring Payment
}
@dialog.Description() {
This will stop future automatic payments. Past payments are not affected.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s", spaceID, loanID, rr.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Available balance: { model.FormatMoney(availableBalance) }
</p>
<div id="funding-sources" class="space-y-2">
<div class="flex gap-2 items-center source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ model.FormatMoney(acct.Balance) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Record Payment
}
}
</form>
<script>
// Update hidden account_id when select changes
document.getElementById('funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Frequency
}
<select name="frequency" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" required>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="yearly">Yearly</option>
</select>
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Current balance: { model.FormatMoney(availableBalance) }
</p>
<div id="recurring-funding-sources" class="space-y-2">
<div class="flex gap-2 items-center recurring-source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ model.FormatMoney(acct.Balance) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .recurring-source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #recurring-funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Recurring Payment
}
}
</form>
<script>
document.getElementById('recurring-funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}

View file

@ -1,235 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoansPage(space *model.Space, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
@layouts.Space("Loans", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Loans</h1>
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
New Loan
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
New Loan
}
@dialog.Description() {
Track a new loan or financing
}
}
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans", space.ID) }
hx-target="#loans-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterRequest if event.detail.successful call window.tui.dialog.close() then reset() me"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Name
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "name",
Placeholder: "e.g., Car Loan",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Total Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01",
"required": "true",
},
})
}
@form.Item() {
@form.Label() {
Interest Rate (%)
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "interest_rate",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0",
},
})
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Additional notes about this loan",
})
}
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Loan
}
}
</form>
}
}
</div>
<div id="loans-list-wrapper">
@LoansListContent(space.ID, loans, currentPage, totalPages)
</div>
</div>
}
}
templ LoansListContent(spaceID string, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(loans) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No loans yet. Create one to start tracking payments.</p>
}
for _, loan := range loans {
@LoanCard(spaceID, loan)
}
</div>
if totalPages > 1 {
<div class="mt-4">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage-1),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, pg),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage+1),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if !loan.OriginalAmount.IsZero() {
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
<a href={ templ.SafeURL(fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID)) } class="block">
@card.Card(card.Props{Class: "hover:border-primary/50 transition-colors cursor-pointer"}) {
@card.Header() {
<div class="flex justify-between items-start">
@card.Title() {
{ loan.Name }
}
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
</div>
@card.Description() {
{ model.FormatMoney(loan.OriginalAmount) }
if loan.InterestRateBps > 0 {
{ fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) }
}
}
}
@card.Content() {
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-2",
})
<div class="flex justify-between text-sm text-muted-foreground mt-2">
<span>Paid: { model.FormatMoney(loan.TotalPaid) }</span>
if loan.Remaining.GreaterThan(decimal.Zero) {
<span>Left: { model.FormatMoney(loan.Remaining) }</span>
} else {
<span class="text-green-600">Fully paid</span>
}
</div>
<p class="text-xs text-muted-foreground mt-1">
{ strconv.Itoa(loan.ReceiptCount) } payment(s)
</p>
}
}
</a>
}

View file

@ -1,385 +0,0 @@
package pages
import (
"fmt"
"sort"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
)
type OverviewData struct {
Space *model.Space
Balance decimal.Decimal
Allocated decimal.Decimal
Report *model.SpendingReport
Budgets []*model.BudgetWithSpent
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
ShoppingLists []model.ListCardData
Tags []*model.Tag
Methods []*model.PaymentMethod
ListsWithItems []model.ListWithUncheckedItems
}
func overviewProgressBarColor(status model.BudgetStatus) string {
switch status {
case model.BudgetStatusOver:
return "bg-destructive"
case model.BudgetStatusWarning:
return "bg-yellow-500"
default:
return "bg-green-500"
}
}
func overviewPeriodLabel(p model.BudgetPeriod) string {
switch p {
case model.BudgetPeriodWeekly:
return "Weekly"
case model.BudgetPeriodYearly:
return "Yearly"
default:
return "Monthly"
}
}
func overviewFrequencyLabel(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)
}
}
func sortedActiveRecurring(recs []*model.RecurringExpenseWithTagsAndMethod) []*model.RecurringExpenseWithTagsAndMethod {
var active []*model.RecurringExpenseWithTagsAndMethod
for _, r := range recs {
if r.IsActive {
active = append(active, r)
}
}
sort.Slice(active, func(i, j int) bool {
return active[i].NextOccurrence.Before(active[j].NextOccurrence)
})
return active
}
func uncheckedCount(items []*model.ListItem) int {
count := 0
for _, item := range items {
if !item.IsChecked {
count++
}
}
return count
}
var overviewChartColors = []string{
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
}
func overviewChartColor(i int, tagColor *string) string {
if tagColor != nil && *tagColor != "" {
return *tagColor
}
return overviewChartColors[i%len(overviewChartColors)]
}
templ SpaceOverviewPage(data OverviewData) {
@layouts.Space("Overview", data.Space) {
@chart.Script()
<div class="space-y-4">
<h1 class="text-2xl font-bold">Overview</h1>
<div class="grid gap-4 md:grid-cols-2">
// Row 1: Balance + This Month summary
@overviewBalanceCard(data)
@overviewSpendingCard(data)
// Row 2: Charts
@overviewSpendingByCategoryChart(data)
@overviewSpendingOverTimeChart(data)
// Row 3+: Detail cards
@overviewBudgetsCard(data)
@overviewRecurringCard(data)
@overviewTopExpensesCard(data)
@overviewShoppingListsCard(data)
</div>
</div>
}
}
templ overviewSectionHeader(title, href string) {
<div class="flex justify-between items-center mb-3">
<h3 class="font-semibold">{ title }</h3>
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeSm,
Attributes: templ.Attributes{
"onclick": "window.location.href='" + href + "'",
},
}) {
<span>View all</span>
@icon.ChevronRight(icon.Props{Size: 16})
}
</div>
}
templ overviewBalanceCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
<div class="flex items-center justify-between">
<h3 class="font-semibold mb-3">Current Balance</h3>
@dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods)
</div>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance.IsNegative()) }>
{ model.FormatMoney(data.Balance) }
</p>
if data.Allocated.GreaterThan(decimal.Zero) {
<p class="text-sm text-muted-foreground mt-1">
{ model.FormatMoney(data.Allocated) } in accounts
</p>
}
</div>
}
templ overviewSpendingCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("This Month", "/app/spaces/"+data.Space.ID+"/reports")
if data.Report != nil {
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-sm text-green-500 font-medium">Income</span>
<span class="text-sm font-bold text-green-500">{ model.FormatMoney(data.Report.TotalIncome) }</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-destructive font-medium">Expenses</span>
<span class="text-sm font-bold text-destructive">{ model.FormatMoney(data.Report.TotalExpenses) }</span>
</div>
<hr class="border-border"/>
<div class="flex justify-between">
<span class="text-sm font-medium">Net</span>
<span class={ "text-sm font-bold", templ.KV("text-green-500", !data.Report.NetBalance.IsNegative()), templ.KV("text-destructive", data.Report.NetBalance.IsNegative()) }>
{ model.FormatMoney(data.Report.NetBalance) }
</span>
</div>
</div>
} else {
<p class="text-sm text-muted-foreground">No data available.</p>
}
</div>
}
templ overviewSpendingByCategoryChart(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Category</h3>
if data.Report != nil && len(data.Report.ByTag) > 0 {
{{
tagLabels := make([]string, len(data.Report.ByTag))
tagData := make([]float64, len(data.Report.ByTag))
tagColors := make([]string, len(data.Report.ByTag))
for i, t := range data.Report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = overviewChartColor(i, t.TagColor)
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: tagLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: tagData,
BackgroundColor: tagColors,
},
},
},
})
} else {
<p class="text-sm text-muted-foreground">No tagged expenses this month.</p>
}
</div>
}
templ overviewSpendingOverTimeChart(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
if data.Report != nil && len(data.Report.DailySpending) > 0 {
{{
var timeLabels []string
var timeData []float64
for _, d := range data.Report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, d.Total.InexactFloat64())
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantBar,
ShowYAxis: true,
ShowXAxis: true,
ShowXLabels: true,
ShowYLabels: true,
Data: chart.Data{
Labels: timeLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: timeData,
BackgroundColor: "#3b82f6",
},
},
},
})
} else {
<p class="text-sm text-muted-foreground">No expenses this month.</p>
}
</div>
}
templ overviewBudgetsCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Budget Status", "/app/spaces/"+data.Space.ID+"/budgets")
if len(data.Budgets) == 0 {
<p class="text-sm text-muted-foreground">No budgets set up yet.</p>
} else {
<div class="space-y-3">
for i, b := range data.Budgets {
if i < 3 {
{{ pct := b.Percentage }}
if pct > 100 {
{{ pct = 100 }}
}
<div class="space-y-1">
<div class="flex items-center gap-2 flex-wrap">
for _, t := range b.Tags {
<span class="inline-flex items-center gap-1">
if t.Color != nil {
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *t.Color }></span>
}
<span class="text-sm font-medium">{ t.Name }</span>
</span>
}
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div>
</div>
}
}
</div>
}
</div>
}
templ overviewRecurringCard(data OverviewData) {
{{ upcoming := sortedActiveRecurring(data.UpcomingRecurring) }}
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Upcoming Recurring", "/app/spaces/"+data.Space.ID+"/recurring")
if len(upcoming) == 0 {
<p class="text-sm text-muted-foreground">No active recurring payments.</p>
} else {
<div class="divide-y">
for i, r := range upcoming {
if i < 5 {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{ r.Description }</p>
<p class="text-xs text-muted-foreground">
{ overviewFrequencyLabel(r.Frequency) } &middot; Next: { r.NextOccurrence.Format("Jan 02") }
</p>
</div>
if r.Type == model.ExpenseTypeExpense {
<p class="text-sm font-bold text-destructive shrink-0">
{ model.FormatMoney(r.Amount) }
</p>
} else {
<p class="text-sm font-bold text-green-500 shrink-0">
+{ model.FormatMoney(r.Amount) }
</p>
}
</div>
}
}
</div>
}
</div>
}
templ overviewTopExpensesCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Top Expenses", "/app/spaces/"+data.Space.ID+"/expenses")
if data.Report == nil || len(data.Report.TopExpenses) == 0 {
<p class="text-sm text-muted-foreground">No expenses this month.</p>
} else {
<div class="divide-y">
for i, exp := range data.Report.TopExpenses {
if i < 5 {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{ exp.Description }</p>
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-0.5">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<p class="text-sm font-bold text-destructive shrink-0">
{ model.FormatMoney(exp.Amount) }
</p>
</div>
}
}
</div>
}
</div>
}
templ overviewShoppingListsCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Shopping Lists", "/app/spaces/"+data.Space.ID+"/lists")
if len(data.ShoppingLists) == 0 {
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
} else {
<div class="divide-y">
for i, card := range data.ShoppingLists {
if i < 3 {
<a href={ templ.SafeURL("/app/spaces/" + data.Space.ID + "/lists/" + card.List.ID) } class="py-2 flex justify-between items-center hover:bg-accent/50 -mx-1 px-1 rounded transition-colors">
<span class="text-sm font-medium">{ card.List.Name }</span>
{{ uc := uncheckedCount(card.Items) }}
if uc > 0 {
<span class="text-xs text-muted-foreground">{ fmt.Sprintf("%d unchecked", uc) }</span>
} else {
<span class="text-xs text-muted-foreground">All done</span>
}
</a>
}
}
</div>
}
</div>
}

View file

@ -1,45 +0,0 @@
package pages
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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpacePaymentMethodsPage(space *model.Space, methods []*model.PaymentMethod) {
@layouts.Space("Payment Methods", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Payment Methods</h1>
@dialog.Dialog(dialog.Props{ID: "add-method-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Method
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Payment Method
}
@dialog.Description() {
Add a credit or debit card to track how you pay for expenses.
}
}
@paymentmethod.CreateMethodForm(space.ID, "add-method-dialog")
}
}
</div>
<div id="methods-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(methods) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No payment methods yet. Add one to start tracking how you pay for expenses.</p>
}
for _, method := range methods {
@paymentmethod.MethodItem(space.ID, method)
}
</div>
</div>
}
}

View file

@ -1,47 +0,0 @@
package pages
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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithTagsAndMethod, tags []*model.Tag, methods []*model.PaymentMethod) {
@layouts.Space("Recurring", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Recurring Transactions</h1>
@dialog.Dialog(dialog.Props{ID: "add-recurring-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Recurring
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Recurring Transaction
}
@dialog.Description() {
Set up a recurring expense or top-up that will auto-generate on schedule.
}
}
@recurring.AddRecurringForm(space.ID, tags, methods, "add-recurring-dialog")
}
}
</div>
<div class="border rounded-lg">
<div id="recurring-list" class="divide-y">
if len(recs) == 0 {
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
}
for _, re := range recs {
@recurring.RecurringItem(space.ID, re, methods, tags)
}
</div>
</div>
</div>
}
}

View file

@ -1,236 +0,0 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
)
var defaultChartColors = []string{
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
}
func chartColor(i int, tagColor *string) string {
if tagColor != nil && *tagColor != "" {
return *tagColor
}
return defaultChartColors[i%len(defaultChartColors)]
}
templ SpaceReportsPage(space *model.Space, report *model.SpendingReport, presets []service.DateRange, activeRange string) {
@layouts.Space("Reports", space) {
@chart.Script()
<div class="space-y-4">
<h1 class="text-2xl font-bold">Reports</h1>
<div id="report-content">
@ReportCharts(space.ID, report, presets[0].From, presets[0].To, presets, activeRange)
</div>
</div>
}
}
templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.Time, presets []service.DateRange, activeRange string) {
// Date range selector
<div class="flex flex-wrap gap-2 items-center mb-4">
for _, p := range presets {
if p.Key == activeRange {
@button.Button(button.Props{
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
} else {
@button.Button(button.Props{
Variant: button.VariantOutline,
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
}
}
</div>
<div class="grid gap-4 md:grid-cols-2 overflow-hidden">
// Income vs Expenses Summary
<div class="border rounded-lg p-4 bg-card text-card-foreground space-y-2 min-w-0">
<h3 class="font-semibold">Income vs Expenses</h3>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-green-500 font-medium">Income</span>
<span class="font-bold text-green-500">{ model.FormatMoney(report.TotalIncome) }</span>
</div>
<div class="flex justify-between">
<span class="text-destructive font-medium">Expenses</span>
<span class="font-bold text-destructive">{ model.FormatMoney(report.TotalExpenses) }</span>
</div>
<hr class="border-border"/>
<div class="flex justify-between">
<span class="font-medium">Net</span>
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance.GreaterThanOrEqual(decimal.Zero)), templ.KV("text-destructive", report.NetBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(report.NetBalance) }
</span>
</div>
</div>
</div>
// Spending by Tag (Doughnut chart)
if len(report.ByTag) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Category</h3>
{{
tagLabels := make([]string, len(report.ByTag))
tagData := make([]float64, len(report.ByTag))
tagColors := make([]string, len(report.ByTag))
for i, t := range report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = chartColor(i, t.TagColor)
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: tagLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: tagData,
BackgroundColor: tagColors,
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending by Category</h3>
<p class="text-sm text-muted-foreground">No tagged expenses in this period.</p>
</div>
}
// Spending by Payment Method (Doughnut chart)
if len(report.ByPaymentMethod) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
{{
pmLabels := make([]string, len(report.ByPaymentMethod))
pmData := make([]float64, len(report.ByPaymentMethod))
pmColors := make([]string, len(report.ByPaymentMethod))
for i, pm := range report.ByPaymentMethod {
pmLabels[i] = pm.PaymentMethodName
pmData[i] = pm.TotalAmount.InexactFloat64()
pmColors[i] = defaultChartColors[i%len(defaultChartColors)]
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: pmLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: pmData,
BackgroundColor: pmColors,
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
<p class="text-sm text-muted-foreground">No payment method data in this period.</p>
</div>
}
// Spending Over Time (Bar chart)
if len(report.DailySpending) > 0 || len(report.MonthlySpending) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
{{
days := to.Sub(from).Hours() / 24
var timeLabels []string
var timeData []float64
if days <= 31 {
for _, d := range report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, d.Total.InexactFloat64())
}
} else {
for _, m := range report.MonthlySpending {
timeLabels = append(timeLabels, m.Month)
timeData = append(timeData, m.Total.InexactFloat64())
}
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantBar,
ShowYAxis: true,
ShowXAxis: true,
ShowXLabels: true,
ShowYLabels: true,
Data: chart.Data{
Labels: timeLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: timeData,
BackgroundColor: "#3b82f6",
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
</div>
}
// Top 10 Expenses
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Top Expenses</h3>
if len(report.TopExpenses) == 0 {
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
} else {
<div class="divide-y">
for _, exp := range report.TopExpenses {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium text-sm truncate">{ exp.Description }</p>
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-0.5">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<p class="font-bold text-destructive text-sm shrink-0">
{ model.FormatMoney(exp.Amount) }
</p>
</div>
}
</div>
}
</div>
</div>
}

View file

@ -1,402 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/selectbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) {
@layouts.Space("Settings", space) {
<div class="space-y-6 max-w-2xl">
// Space Name Section
@card.Card() {
@card.Header() {
@card.Title() {
Space Name
}
@card.Description() {
if isOwner {
Update the name of this space.
} else {
The name of this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
hx-swap="none"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: space.Name,
Attributes: templ.Attributes{
"autocomplete": "off",
"required": true,
},
})
@button.Submit() {
Save
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Timezone Section
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
if isOwner {
Set a timezone for this space. Recurring expenses and reports will use this timezone.
} else {
The timezone used for recurring expenses and reports in this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/timezone" }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "space-timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: space.Timezone != nil && tz.Value == *space.Timezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Save Timezone
}
</form>
} else {
if space.Timezone != nil && *space.Timezone != "" {
<p class="text-sm">{ *space.Timezone }</p>
} else {
<p class="text-sm text-muted-foreground">Not set (uses your timezone)</p>
}
}
}
}
// Members Section
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Users(icon.Props{Class: "size-5"})
Members
</div>
}
@card.Description() {
People who have access to this space.
}
}
@card.Content() {
<div class="divide-y" id="members-list">
for _, member := range members {
@MemberRow(space.ID, member, isOwner, currentUserID)
}
</div>
}
}
// Invitations Section (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Mail(icon.Props{Class: "size-5"})
Invitations
</div>
}
@card.Description() {
Invite new members and manage pending invitations.
}
}
@card.Content() {
<div class="space-y-4">
<form
hx-post={ "/app/spaces/" + space.ID + "/invites" }
hx-swap="none"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "email",
Placeholder: "Email address...",
Attributes: templ.Attributes{
"type": "email",
"autocomplete": "off",
"required": true,
},
})
@button.Submit() {
@icon.UserPlus(icon.Props{Class: "size-4"})
Invite
}
</form>
<div
id="pending-invites"
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
hx-trigger="refreshInvites from:body"
hx-swap="innerHTML"
>
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(space.ID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
</div>
</div>
}
}
}
// Danger Zone (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2 text-destructive">
@icon.TriangleAlert(icon.Props{Class: "size-5"})
Danger Zone
</div>
}
@card.Description() {
Irreversible and destructive actions.
}
}
@card.Content() {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Delete this space</p>
<p class="text-sm text-muted-foreground">Once deleted, all data in this space will be permanently removed.</p>
</div>
{{ deleteDialogID := "delete-space-dialog-" + space.ID }}
@dialog.Dialog(dialog.Props{ID: deleteDialogID, DisableClickAway: true}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
}) {
Delete Space
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete space
}
@dialog.Description() {
This action is permanent and cannot be undone. All data including expenses, budgets, shopping lists, and members will be permanently deleted.
}
}
<form
hx-delete={ "/app/spaces/" + space.ID }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "confirmation_name"}) {
Type <span class="font-semibold">{ space.Name }</span> to confirm
}
@input.Input(input.Props{
Name: "confirmation_name",
Placeholder: space.Name,
Attributes: templ.Attributes{
"id": "confirmation_name",
"autocomplete": "off",
},
})
</div>
<div class="flex justify-end gap-2">
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeSubmit,
Attributes: templ.Attributes{
"id": "delete-space-confirm",
},
}) {
Delete Space
}
</div>
</form>
}
}
</div>
}
}
}
</div>
@dialog.Script()
}
}
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{ string([]rune(member.Name)[0]) }
</div>
<div>
<p class="text-sm font-medium">{ member.Name }</p>
<p class="text-xs text-muted-foreground">{ member.Email }</p>
</div>
</div>
<div class="flex items-center gap-2">
if member.Role == model.RoleOwner {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
@icon.Crown(icon.Props{Class: "size-3"})
Owner
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Member
}
}
if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner {
{{ dialogID := "remove-member-dialog-" + member.UserID }}
@dialog.Dialog(dialog.Props{ID: dialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
}) {
@icon.UserMinus(icon.Props{Class: "size-4 text-destructive"})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Remove member
}
@dialog.Description() {
Are you sure you want to remove { member.Name } from this space? They will lose access immediately.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID,
"hx-target": "#member-" + member.UserID,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
Remove
}
}
}
}
}
}
</div>
</div>
}
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div>
<p class="text-sm font-medium">{ invite.Email }</p>
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
</div>
</div>
<div class="flex items-center gap-2">
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
Pending
}
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token,
"hx-target": "#invite-" + invite.Token,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
@icon.X(icon.Props{Class: "size-4 text-destructive"})
}
</div>
</div>
}
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(spaceID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
}

View file

@ -1,47 +0,0 @@
package pages
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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
@layouts.Space("Tags", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Tags</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/tags" }
hx-target="#tags-container"
hx-swap="beforeend"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New tag name...",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Submit() {
Create
}
</form>
<div
id="tags-container"
class="flex flex-wrap gap-2"
>
for _, t := range tags {
@tag.Tag(t)
}
</div>
</div>
}
}