diff --git a/internal/app/app.go b/internal/app/app.go index eb37361..021b5f7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() diff --git a/internal/ctxkeys/ctx.go b/internal/ctxkeys/ctx.go index a4d5e4c..ed634b9 100644 --- a/internal/ctxkeys/ctx.go +++ b/internal/ctxkeys/ctx.go @@ -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) diff --git a/internal/db/migrations/00002_create_workspace_collaboration_tables.sql b/internal/db/migrations/00002_create_workspace_collaboration_tables.sql index 0359ee7..1a60005 100644 --- a/internal/db/migrations/00002_create_workspace_collaboration_tables.sql +++ b/internal/db/migrations/00002_create_workspace_collaboration_tables.sql @@ -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 diff --git a/internal/handler/account_handler.go b/internal/handler/account_handler.go deleted file mode 100644 index f95940e..0000000 --- a/internal/handler/account_handler.go +++ /dev/null @@ -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)) -} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 6f4395d..de1ecad 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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) +} diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go index f59af03..51d09f5 100644 --- a/internal/handler/auth_test.go +++ b/internal/handler/auth_test.go @@ -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"}, diff --git a/internal/handler/budget_handler.go b/internal/handler/budget_handler.go deleted file mode 100644 index ff5a998..0000000 --- a/internal/handler/budget_handler.go +++ /dev/null @@ -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)) -} diff --git a/internal/handler/expense_handler.go b/internal/handler/expense_handler.go deleted file mode 100644 index 25bde90..0000000 --- a/internal/handler/expense_handler.go +++ /dev/null @@ -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)) -} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go deleted file mode 100644 index 90fe175..0000000 --- a/internal/handler/helpers.go +++ /dev/null @@ -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 -} diff --git a/internal/handler/home_test.go b/internal/handler/home_test.go index dd64d06..8f80168 100644 --- a/internal/handler/home_test.go +++ b/internal/handler/home_test.go @@ -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) diff --git a/internal/handler/list_handler.go b/internal/handler/list_handler.go deleted file mode 100644 index 4de5d09..0000000 --- a/internal/handler/list_handler.go +++ /dev/null @@ -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 -} diff --git a/internal/handler/method_handler.go b/internal/handler/method_handler.go deleted file mode 100644 index 5a0b1bd..0000000 --- a/internal/handler/method_handler.go +++ /dev/null @@ -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, - })) -} diff --git a/internal/handler/recurring_handler.go b/internal/handler/recurring_handler.go deleted file mode 100644 index 3f638c1..0000000 --- a/internal/handler/recurring_handler.go +++ /dev/null @@ -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)) -} diff --git a/internal/handler/settings.go b/internal/handler/settings.go index e96d54a..bf80a63 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -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, - })) -} diff --git a/internal/handler/settings_test.go b/internal/handler/settings_test.go index 8d1394c..b0c6ac3 100644 --- a/internal/handler/settings_test.go +++ b/internal/handler/settings_test.go @@ -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"}, }) diff --git a/internal/handler/space.go b/internal/handler/space.go deleted file mode 100644 index 6959405..0000000 --- a/internal/handler/space.go +++ /dev/null @@ -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, `

Space name is required

`) - 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 -} diff --git a/internal/handler/space_loans.go b/internal/handler/space_loans.go deleted file mode 100644 index 50b915a..0000000 --- a/internal/handler/space_loans.go +++ /dev/null @@ -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 -} diff --git a/internal/handler/space_settings_handler.go b/internal/handler/space_settings_handler.go deleted file mode 100644 index b0239e4..0000000 --- a/internal/handler/space_settings_handler.go +++ /dev/null @@ -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) -} diff --git a/internal/handler/space_test.go b/internal/handler/space_test.go deleted file mode 100644 index 1c6c6d5..0000000 --- a/internal/handler/space_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/handler/tag_handler.go b/internal/handler/tag_handler.go deleted file mode 100644 index fa8d3a1..0000000 --- a/internal/handler/tag_handler.go +++ /dev/null @@ -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, - })) -} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 2f6619a..7d0e735 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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 } diff --git a/internal/model/workspace_collaboration.go b/internal/model/workspace_collaboration.go index e22e009..1de8f8a 100644 --- a/internal/model/workspace_collaboration.go +++ b/internal/model/workspace_collaboration.go @@ -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"` } diff --git a/internal/repository/budget.go b/internal/repository/budget.go deleted file mode 100644 index 3374c85..0000000 --- a/internal/repository/budget.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/expense.go b/internal/repository/expense.go deleted file mode 100644 index 6d75548..0000000 --- a/internal/repository/expense.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/expense_test.go b/internal/repository/expense_test.go deleted file mode 100644 index cc72577..0000000 --- a/internal/repository/expense_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/list_item.go b/internal/repository/list_item.go deleted file mode 100644 index b8b39a5..0000000 --- a/internal/repository/list_item.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/list_item_test.go b/internal/repository/list_item_test.go deleted file mode 100644 index 68b2414..0000000 --- a/internal/repository/list_item_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/loan.go b/internal/repository/loan.go deleted file mode 100644 index b089b01..0000000 --- a/internal/repository/loan.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/money_account.go b/internal/repository/money_account.go deleted file mode 100644 index 16dc008..0000000 --- a/internal/repository/money_account.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/money_account_test.go b/internal/repository/money_account_test.go deleted file mode 100644 index 24055f3..0000000 --- a/internal/repository/money_account_test.go +++ /dev/null @@ -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)) - }) -} diff --git a/internal/repository/payment_method.go b/internal/repository/payment_method.go deleted file mode 100644 index 8ceb878..0000000 --- a/internal/repository/payment_method.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/payment_method_test.go b/internal/repository/payment_method_test.go deleted file mode 100644 index 5645481..0000000 --- a/internal/repository/payment_method_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/profile.go b/internal/repository/profile.go deleted file mode 100644 index c25c30a..0000000 --- a/internal/repository/profile.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/profile_test.go b/internal/repository/profile_test.go deleted file mode 100644 index 0074cf2..0000000 --- a/internal/repository/profile_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/receipt.go b/internal/repository/receipt.go deleted file mode 100644 index 806157d..0000000 --- a/internal/repository/receipt.go +++ /dev/null @@ -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() -} diff --git a/internal/repository/recurring_expense.go b/internal/repository/recurring_expense.go deleted file mode 100644 index 2501998..0000000 --- a/internal/repository/recurring_expense.go +++ /dev/null @@ -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) -} diff --git a/internal/repository/recurring_receipt.go b/internal/repository/recurring_receipt.go deleted file mode 100644 index f50fa93..0000000 --- a/internal/repository/recurring_receipt.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/shopping_list.go b/internal/repository/shopping_list.go deleted file mode 100644 index 798432c..0000000 --- a/internal/repository/shopping_list.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/shopping_list_test.go b/internal/repository/shopping_list_test.go deleted file mode 100644 index 3cf2bd5..0000000 --- a/internal/repository/shopping_list_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/space.go b/internal/repository/space.go index a1b4f6b..764c299 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -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;` diff --git a/internal/repository/space_test.go b/internal/repository/space_test.go index 88f6570..a115b47 100644 --- a/internal/repository/space_test.go +++ b/internal/repository/space_test.go @@ -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) }) } diff --git a/internal/repository/tag.go b/internal/repository/tag.go deleted file mode 100644 index 09446ee..0000000 --- a/internal/repository/tag.go +++ /dev/null @@ -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 -} diff --git a/internal/repository/tag_test.go b/internal/repository/tag_test.go deleted file mode 100644 index 1b89ee4..0000000 --- a/internal/repository/tag_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/repository/user.go b/internal/repository/user.go index 76a66ba..7b89cdb 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -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 } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 9a4fbef..25504c7 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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, ) diff --git a/internal/service/auth.go b/internal/service/auth.go index 370c9b1..4fc789f 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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) diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 0cacfde..efa7f90 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -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) }) } diff --git a/internal/service/budget.go b/internal/service/budget.go deleted file mode 100644 index 736b377..0000000 --- a/internal/service/budget.go +++ /dev/null @@ -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 - } -} diff --git a/internal/service/expense.go b/internal/service/expense.go deleted file mode 100644 index d9d6adb..0000000 --- a/internal/service/expense.go +++ /dev/null @@ -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 -} diff --git a/internal/service/expense_test.go b/internal/service/expense_test.go deleted file mode 100644 index 99f9aaf..0000000 --- a/internal/service/expense_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/loan.go b/internal/service/loan.go deleted file mode 100644 index 914d641..0000000 --- a/internal/service/loan.go +++ /dev/null @@ -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) -} diff --git a/internal/service/money_account.go b/internal/service/money_account.go deleted file mode 100644 index 84002b6..0000000 --- a/internal/service/money_account.go +++ /dev/null @@ -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 -} diff --git a/internal/service/money_account_test.go b/internal/service/money_account_test.go deleted file mode 100644 index fda2819..0000000 --- a/internal/service/money_account_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/pagination.go b/internal/service/pagination.go deleted file mode 100644 index 6904d11..0000000 --- a/internal/service/pagination.go +++ /dev/null @@ -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 -} diff --git a/internal/service/payment_method.go b/internal/service/payment_method.go deleted file mode 100644 index c8d8586..0000000 --- a/internal/service/payment_method.go +++ /dev/null @@ -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) -} diff --git a/internal/service/payment_method_test.go b/internal/service/payment_method_test.go deleted file mode 100644 index 5104c9c..0000000 --- a/internal/service/payment_method_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/profile.go b/internal/service/profile.go deleted file mode 100644 index 54d99f3..0000000 --- a/internal/service/profile.go +++ /dev/null @@ -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) -} diff --git a/internal/service/profile_test.go b/internal/service/profile_test.go deleted file mode 100644 index 25a97fe..0000000 --- a/internal/service/profile_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/receipt.go b/internal/service/receipt.go deleted file mode 100644 index e5f4358..0000000 --- a/internal/service/receipt.go +++ /dev/null @@ -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 -} diff --git a/internal/service/recurring_expense.go b/internal/service/recurring_expense.go deleted file mode 100644 index 0a2a684..0000000 --- a/internal/service/recurring_expense.go +++ /dev/null @@ -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) - } -} diff --git a/internal/service/recurring_receipt.go b/internal/service/recurring_receipt.go deleted file mode 100644 index 3c7747a..0000000 --- a/internal/service/recurring_receipt.go +++ /dev/null @@ -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) -} diff --git a/internal/service/report.go b/internal/service/report.go deleted file mode 100644 index fb6a55c..0000000 --- a/internal/service/report.go +++ /dev/null @@ -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}, - } -} diff --git a/internal/service/shopping_list.go b/internal/service/shopping_list.go deleted file mode 100644 index 442eaa8..0000000 --- a/internal/service/shopping_list.go +++ /dev/null @@ -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) -} diff --git a/internal/service/shopping_list_test.go b/internal/service/shopping_list_test.go deleted file mode 100644 index d9298b1..0000000 --- a/internal/service/shopping_list_test.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/service/space.go b/internal/service/space.go index bd16b5c..6b0ca49 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -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 { diff --git a/internal/service/space_test.go b/internal/service/space_test.go index 83610f4..0d63598 100644 --- a/internal/service/space_test.go +++ b/internal/service/space_test.go @@ -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) }) } diff --git a/internal/service/tag.go b/internal/service/tag.go deleted file mode 100644 index ca164cd..0000000 --- a/internal/service/tag.go +++ /dev/null @@ -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) -} diff --git a/internal/service/tag_test.go b/internal/service/tag_test.go deleted file mode 100644 index bb0fdec..0000000 --- a/internal/service/tag_test.go +++ /dev/null @@ -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) -} diff --git a/internal/testutil/http.go b/internal/testutil/http.go index 6e2f6a2..91c5125 100644 --- a/internal/testutil/http.go +++ b/internal/testutil/http.go @@ -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 diff --git a/internal/testutil/seed.go b/internal/testutil/seed.go index ec9ebe4..d14b1cd 100644 --- a/internal/testutil/seed.go +++ b/internal/testutil/seed.go @@ -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() diff --git a/internal/ui/blocks/dialogs/add_transaction.templ b/internal/ui/blocks/dialogs/add_transaction.templ deleted file mode 100644 index 5dffb31..0000000 --- a/internal/ui/blocks/dialogs/add_transaction.templ +++ /dev/null @@ -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), - }) - } - } -} diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ deleted file mode 100644 index 12bdf5a..0000000 --- a/internal/ui/components/expense/expense.templ +++ /dev/null @@ -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) { -
- @csrf.Token() - if props.RedirectURL != "" { - - } - // Type -
-
- @radio.Radio(radio.Props{ - ID: "expense-type-expense", - Name: "type", - Value: "expense", - Checked: true, - Attributes: templ.Attributes{ - "_": "on click show #item-selector-section", - }, - }) -
- @label.Label(label.Props{ - For: "expense-type-expense", - }) { - Expense - } -
-
-
- @radio.Radio(radio.Props{ - ID: "expense-type-topup", - Name: "type", - Value: "topup", - Attributes: templ.Attributes{ - "_": "on click hide #item-selector-section", - }, - }) -
- @label.Label(label.Props{ - For: "expense-type-topup", - }) { - Top-up - } -
-
-
- // Description -
- @label.Label(label.Props{ - For: "description", - }) { - Description - } - @input.Input(input.Props{ - Name: "description", - ID: "description", - Attributes: templ.Attributes{"required": "true"}, - }) -
- // Amount -
- @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"}, - }) -
- // Date -
- @label.Label(label.Props{ - For: "date", - }) { - Date - } - @datepicker.DatePicker(datepicker.Props{ - ID: "date", - Name: "date", - Clearable: true, - Required: true, - }) -
- // Tags -
- @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...", - }) -
- // Payment Method - @paymentmethod.MethodSelector(props.PaymentMethods, nil) - // Shopping list items selector - @ItemSelectorSection(props.ListsWithItems, false) -
- @button.Submit() { - Save - } -
-
-} - -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 }} - } -
- @csrf.Token() - // Type -
-
- @radio.Radio(radio.Props{ - ID: "edit-type-expense-" + exp.ID, - Name: "type", - Value: "expense", - Checked: exp.Type == model.ExpenseTypeExpense, - }) -
- @label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) { - Expense - } -
-
-
- @radio.Radio(radio.Props{ - ID: "edit-type-topup-" + exp.ID, - Name: "type", - Value: "topup", - Checked: exp.Type == model.ExpenseTypeTopup, - }) -
- @label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) { - Top-up - } -
-
-
- // Description -
- @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"}, - }) -
- // Amount -
- @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"}, - }) -
- // Date -
- @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"}, - }) -
- // Tags -
- @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...", - }) -
- // Payment Method - @paymentmethod.MethodSelector(methods, exp.PaymentMethodID) -
- @button.Submit() { - Save - } -
-
-} - -templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) { -
- @label.Label(label.Props{}) { - Link Shopping List Items - } - if len(listsWithItems) == 0 { -

No unchecked items available.

- } else { -
- for i, lwi := range listsWithItems { - {{ toggleID := "toggle-list-" + lwi.List.ID }} - {{ itemsID := "items-" + lwi.List.ID }} -
-
- @checkbox.Checkbox(checkbox.Props{ - ID: "select-all-" + lwi.List.ID, - Attributes: templ.Attributes{ - "_": "on change repeat for cb in 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 in me", - }, - }) { - @icon.ChevronRight(icon.Props{Size: 14}) - { lwi.List.Name } - - ({ strconv.Itoa(len(lwi.Items)) }) - - } -
- -
- if i < len(listsWithItems) - 1 { -
- } - } -
- // Post-action radio group -
-

After linking items:

-
-
- @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 - } -
-
- @radio.Radio(radio.Props{ - ID: "item-action-delete", - Name: "item_action", - Value: "delete", - }) - @label.Label(label.Props{For: "item-action-delete"}) { - Delete from list - } -
-
-
- } -
-} - -templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) { -
-

Current Balance

-

- { model.FormatMoney(balance) } - if allocated.GreaterThan(decimal.Zero) { - - ({ model.FormatMoney(allocated) } in accounts) - - } -

-
-} diff --git a/internal/ui/components/moneyaccount/moneyaccount.templ b/internal/ui/components/moneyaccount/moneyaccount.templ deleted file mode 100644 index 1eedd79..0000000 --- a/internal/ui/components/moneyaccount/moneyaccount.templ +++ /dev/null @@ -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) { -
-

Balance Summary

-
-
-

Total Balance

-

- { model.FormatMoney(totalBalance) } -

-
-
-

Allocated

-

- { model.FormatMoney(totalBalance.Sub(availableBalance)) } -

-
-
-

Available

-

- { model.FormatMoney(availableBalance) } -

-
-
-
-} - -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 }} -
0 && oob[0] { - hx-swap-oob="true" - } - > -
-
-

{ acct.Name }

-

- { model.FormatMoney(acct.Balance) } -

-
-
- // 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 - } - } - } - } -
-
-
- // 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) - } - } -
-
-} - -templ CreateAccountForm(spaceID string, dialogID string) { -
- @csrf.Token() -
- @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"}, - }) -
-
- @button.Submit() { - Create - } -
-
-} - -templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string) { -
- @csrf.Token() -
- @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"}, - }) -
-
- @button.Submit() { - Save - } -
-
-} - -templ TransferForm(spaceID string, accountID string, direction model.TransferDirection, dialogID string) { - {{ errorID := "transfer-error-" + accountID + "-" + string(direction) }} -
- @csrf.Token() - -
- @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"}, - }) -

-
-
- @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"}, - }) -
-
- @button.Submit() { - if direction == model.TransferDirectionDeposit { - Deposit - } else { - Withdraw - } - } -
-
-} - -templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) { -
-

Transfer History

-
-
- @TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false) -
-
-
-} - -templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) { -
-
- if len(transfers) == 0 { -

No transfers recorded yet.

- } - for _, t := range transfers { - @TransferHistoryItem(spaceID, t) - } -
- if totalPages > 1 { -
- @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", - }, - }) - } - } - } -
- } -
-} - -templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) { -
-
-
-

- if t.Note != "" { - { t.Note } - } else if t.Direction == model.TransferDirectionDeposit { - Deposit - } else { - Withdrawal - } -

- -
-

- { t.CreatedAt.Format("Jan 2, 2006") } · { t.AccountName } -

-
-
- if t.Direction == model.TransferDirectionDeposit { - - +{ model.FormatMoney(t.Amount) } - - } else { - - -{ model.FormatMoney(t.Amount) } - - } - @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}) - } -
-
-} diff --git a/internal/ui/components/paymentmethod/paymentmethod.templ b/internal/ui/components/paymentmethod/paymentmethod.templ deleted file mode 100644 index c298bd2..0000000 --- a/internal/ui/components/paymentmethod/paymentmethod.templ +++ /dev/null @@ -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 }} -
-
-
-

{ method.Name }

-

- { methodDisplay(method) } -

-
-
- @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 - } - } - } - } -
-
-
-} - -templ CreateMethodForm(spaceID string, dialogID string) { -
- @csrf.Token() -
- @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"}, - }) -
-
- @label.Label(label.Props{}) { - Type - } -
-
- @radio.Radio(radio.Props{ - ID: "method-type-credit", - Name: "type", - Value: "credit", - Checked: true, - }) - @label.Label(label.Props{For: "method-type-credit"}) { - Credit - } -
-
- @radio.Radio(radio.Props{ - ID: "method-type-debit", - Name: "type", - Value: "debit", - }) - @label.Label(label.Props{For: "method-type-debit"}) { - Debit - } -
-
-
-
- @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", - }, - }) -
-
- @button.Submit() { - Create - } -
-
-} - -templ EditMethodForm(spaceID string, method *model.PaymentMethod, dialogID string) { - {{ lastFourVal := "" }} - if method.LastFour != nil { - {{ lastFourVal = *method.LastFour }} - } -
- @csrf.Token() -
- @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"}, - }) -
-
- @label.Label(label.Props{}) { - Type - } -
-
- @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 - } -
-
- @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 - } -
-
-
-
- @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}", - }, - }) -
-
- @button.Submit() { - Save - } -
-
-} - -templ MethodSelector(methods []*model.PaymentMethod, selectedMethodID *string) { -
- @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 } - } - } - } - } - } -
-} diff --git a/internal/ui/components/recurring/recurring.templ b/internal/ui/components/recurring/recurring.templ deleted file mode 100644 index 8958327..0000000 --- a/internal/ui/components/recurring/recurring.templ +++ /dev/null @@ -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 }} -
-
-
-

{ re.Description }

- if !re.IsActive { - @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { - Paused - } - } -
-
- @badge.Badge(badge.Props{Variant: badge.VariantOutline}) { - { frequencyLabel(re.Frequency) } - } - Next: { re.NextOccurrence.Format("Jan 02, 2006") } - if re.PaymentMethod != nil { - if re.PaymentMethod.LastFour != nil { - · { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour }) - } else { - · { re.PaymentMethod.Name } - } - } -
- if len(re.Tags) > 0 { -
- for _, t := range re.Tags { - @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { - { t.Name } - } - } -
- } -
-
- if re.Type == model.ExpenseTypeExpense { -

- - { model.FormatMoney(re.Amount) } -

- } else { -

- + { model.FormatMoney(re.Amount) } -

- } - // 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 - } - } - } - } -
-
-} - -templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.PaymentMethod, dialogID string) { -
- @csrf.Token() - // Type -
-
- @radio.Radio(radio.Props{ - ID: "recurring-type-expense", - Name: "type", - Value: "expense", - Checked: true, - }) -
- @label.Label(label.Props{For: "recurring-type-expense"}) { - Expense - } -
-
-
- @radio.Radio(radio.Props{ - ID: "recurring-type-topup", - Name: "type", - Value: "topup", - }) -
- @label.Label(label.Props{For: "recurring-type-topup"}) { - Top-up - } -
-
-
- // Description -
- @label.Label(label.Props{For: "recurring-description"}) { - Description - } - @input.Input(input.Props{ - Name: "description", - ID: "recurring-description", - Attributes: templ.Attributes{"required": "true"}, - }) -
- // Amount -
- @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"}, - }) -
- // Frequency -
- @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 - } - } - } -
- // Start Date -
- @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, - }) -
- // End Date (optional) -
- @label.Label(label.Props{For: "recurring-end-date"}) { - End Date (optional) - } - @datepicker.DatePicker(datepicker.Props{ - ID: "recurring-end-date", - Name: "end_date", - Clearable: true, - }) -
- // Tags -
- @label.Label(label.Props{For: "recurring-tags"}) { - Tags - } - @tagcombobox.TagCombobox(tagcombobox.Props{ - ID: "recurring-tags", - Name: "tags", - Tags: tags, - Placeholder: "Search or create tags...", - }) -
- // Payment Method - @paymentmethod.MethodSelector(methods, nil) -
- @button.Submit() { - Save - } -
-
-} - -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 }} - } -
- @csrf.Token() - // Type -
-
- @radio.Radio(radio.Props{ - ID: "edit-recurring-type-expense-" + re.ID, - Name: "type", - Value: "expense", - Checked: re.Type == model.ExpenseTypeExpense, - }) -
- @label.Label(label.Props{For: "edit-recurring-type-expense-" + re.ID}) { - Expense - } -
-
-
- @radio.Radio(radio.Props{ - ID: "edit-recurring-type-topup-" + re.ID, - Name: "type", - Value: "topup", - Checked: re.Type == model.ExpenseTypeTopup, - }) -
- @label.Label(label.Props{For: "edit-recurring-type-topup-" + re.ID}) { - Top-up - } -
-
-
- // Description -
- @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"}, - }) -
- // Amount -
- @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"}, - }) -
- // Frequency -
- @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 - } - } - } -
- // Start Date -
- @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, - }) -
- // End Date (optional) -
- @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, - }) - } -
- // Tags -
- @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...", - }) -
- // Payment Method - @paymentmethod.MethodSelector(methods, re.PaymentMethodID) -
- @button.Submit() { - Save - } -
-
-} diff --git a/internal/ui/components/shoppinglist/shoppinglist.templ b/internal/ui/components/shoppinglist/shoppinglist.templ deleted file mode 100644 index 8ca6eba..0000000 --- a/internal/ui/components/shoppinglist/shoppinglist.templ +++ /dev/null @@ -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) { -
-
- @ListCardHeader(spaceID, list) -
-
-
- @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}) - } -
-
-
- @ListCardItems(spaceID, list.ID, items, currentPage, totalPages) -
-
-} - -// ListCardHeader renders the card header with name display, edit form, and delete button. -templ ListCardHeader(spaceID string, list *model.ShoppingList) { -
-

{ list.Name }

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

No items yet

- } else { -
- for _, item := range items { - @CardItemDetail(spaceID, item) - } -
- } - if totalPages > 1 { -
- @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", - }, - }) - } - } - } -
- } -} - -// CardItemDetail renders an item within a card. Toggle is in-place, delete triggers a refresh. -templ CardItemDetail(spaceID string, item *model.ListItem) { -
- @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", - }, - }) - { item.Name } - @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}) - } -
-} - -// ListNameHeader is used on the detail page for editing list name inline. -templ ListNameHeader(spaceID string, list *model.ShoppingList) { -
-

{ list.Name }

- @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 in #list-name-edit", - }, - }) { - @icon.Pencil(icon.Props{Size: 16}) - } -
- -} - -// ItemDetail renders an individual item row (used by the detail page and toggle responses). -templ ItemDetail(spaceID string, item *model.ListItem) { -
- @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", - }, - }) - { item.Name } - @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}) - } -
-} diff --git a/internal/ui/components/tag/tag.templ b/internal/ui/components/tag/tag.templ deleted file mode 100644 index 01b716c..0000000 --- a/internal/ui/components/tag/tag.templ +++ /dev/null @@ -1,23 +0,0 @@ -package tag - -import "git.juancwu.dev/juancwu/budgit/internal/model" - -templ Tag(tag *model.Tag) { -
- if tag.Color != nil { - - } - { tag.Name } - -
-} diff --git a/internal/ui/components/tagcombobox/tagcombobox.templ b/internal/ui/components/tagcombobox/tagcombobox.templ deleted file mode 100644 index 8f945b7..0000000 --- a/internal/ui/components/tagcombobox/tagcombobox.templ +++ /dev/null @@ -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) { -
- // Main input area styled like tagsinput -
- // Selected tag chips -
- for _, val := range props.Value { - @badge.Badge(badge.Props{ - Attributes: templ.Attributes{"data-tagcombobox-chip": val}, - }) { - { val } - - } - } -
- // Text input for searching/typing - -
- // Dropdown - - // Hidden inputs for form submission -
- for _, val := range props.Value { - - } -
-
-} - -templ Script() { - -} diff --git a/internal/ui/layouts/app.templ b/internal/ui/layouts/app.templ index 8fd772e..f50960e 100644 --- a/internal/ui/layouts/app.templ +++ b/internal/ui/layouts/app.templ @@ -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) { }) { @icon.ChevronsUpDown(icon.Props{Class: "ml-auto size-4"}) @@ -143,7 +141,7 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {