chore: massive reset
This commit is contained in:
parent
c7ee3da8f2
commit
df164ab0f4
96 changed files with 198 additions and 15405 deletions
|
|
@ -16,20 +16,8 @@ type App struct {
|
|||
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
|
||||
}
|
||||
|
||||
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,19 +59,7 @@ 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,
|
||||
|
|
@ -104,22 +67,11 @@ func New(cfg *config.Config) (*App, error) {
|
|||
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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
if a.DB != nil {
|
||||
return a.DB.Close()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -15,25 +15,15 @@ import (
|
|||
type settingsHandler struct {
|
||||
authService *service.AuthService
|
||||
userService *service.UserService
|
||||
profileService *service.ProfileService
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
|
||||
)
|
||||
|
||||
type SpaceHandler struct {
|
||||
spaceService *service.SpaceService
|
||||
expenseService *service.ExpenseService
|
||||
accountService *service.MoneyAccountService
|
||||
reportService *service.ReportService
|
||||
budgetService *service.BudgetService
|
||||
recurringService *service.RecurringExpenseService
|
||||
listService *service.ShoppingListService
|
||||
tagService *service.TagService
|
||||
methodService *service.PaymentMethodService
|
||||
loanService *service.LoanService
|
||||
receiptService *service.ReceiptService
|
||||
recurringReceiptService *service.RecurringReceiptService
|
||||
}
|
||||
|
||||
func NewSpaceHandler(
|
||||
ss *service.SpaceService,
|
||||
es *service.ExpenseService,
|
||||
mas *service.MoneyAccountService,
|
||||
rps *service.ReportService,
|
||||
bs *service.BudgetService,
|
||||
rs *service.RecurringExpenseService,
|
||||
sls *service.ShoppingListService,
|
||||
ts *service.TagService,
|
||||
pms *service.PaymentMethodService,
|
||||
ls *service.LoanService,
|
||||
rcs *service.ReceiptService,
|
||||
rrs *service.RecurringReceiptService,
|
||||
) *SpaceHandler {
|
||||
return &SpaceHandler{
|
||||
spaceService: ss,
|
||||
expenseService: es,
|
||||
accountService: mas,
|
||||
reportService: rps,
|
||||
budgetService: bs,
|
||||
recurringService: rs,
|
||||
listService: sls,
|
||||
tagService: ts,
|
||||
methodService: pms,
|
||||
loanService: ls,
|
||||
receiptService: rcs,
|
||||
recurringReceiptService: rrs,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := ctxkeys.User(r.Context())
|
||||
spaces, err := h.spaceService.GetSpacesForUser(user.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get spaces for user", "error", err, "user_id", user.ID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.Dashboard(spaces))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
|
||||
user := ctxkeys.User(r.Context())
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
w.Header().Set("HX-Reswap", "none")
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
fmt.Fprint(w, `<p id="create-space-error" hx-swap-oob="true" class="text-sm text-destructive">Space name is required</p>`)
|
||||
return
|
||||
}
|
||||
|
||||
space, err := h.spaceService.CreateSpace(name, user.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to create space", "error", err, "user_id", user.ID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
space, err := h.spaceService.GetSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get space", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Space not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
|
||||
allocated = decimal.Zero
|
||||
}
|
||||
balance = balance.Sub(allocated)
|
||||
|
||||
// This month's report
|
||||
now := time.Now()
|
||||
presets := service.GetPresetDateRanges(now)
|
||||
report, err := h.reportService.GetSpendingReport(spaceID, presets[0].From, presets[0].To)
|
||||
if err != nil {
|
||||
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
|
||||
report = nil
|
||||
}
|
||||
|
||||
// Budgets
|
||||
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
// Recurring expenses
|
||||
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
// Shopping lists
|
||||
cards, err := h.buildListCards(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
tags, err := h.tagService.GetTagsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get tags for space", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get lists with unchecked items", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
methods, err := h.methodService.GetMethodsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get payment methods", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceOverviewPage(pages.OverviewData{
|
||||
Space: space,
|
||||
Balance: balance,
|
||||
Allocated: allocated,
|
||||
Report: report,
|
||||
Budgets: budgets,
|
||||
UpcomingRecurring: recs,
|
||||
ShoppingLists: cards,
|
||||
Tags: tags,
|
||||
Methods: methods,
|
||||
ListsWithItems: listsWithItems,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) ReportsPage(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
space, err := h.spaceService.GetSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get space", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Space not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
presets := service.GetPresetDateRanges(now)
|
||||
from := presets[0].From
|
||||
to := presets[0].To
|
||||
|
||||
report, err := h.reportService.GetSpendingReport(spaceID, from, to)
|
||||
if err != nil {
|
||||
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceReportsPage(space, report, presets, "this_month"))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
|
||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]model.ListCardData, len(lists))
|
||||
for i, list := range lists {
|
||||
items, totalPages, err := h.listService.GetItemsForListPaginated(list.ID, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards[i] = model.ListCardData{
|
||||
List: list,
|
||||
Items: items,
|
||||
CurrentPage: 1,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
return cards, nil
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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"`
|
||||
InviteeEmail string `db:"invitee_email"`
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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;`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -57,7 +55,6 @@ func NewAuthService(
|
|||
return &AuthService{
|
||||
emailService: emailService,
|
||||
userRepository: userRepository,
|
||||
profileRepository: profileRepository,
|
||||
tokenRepository: tokenRepository,
|
||||
spaceService: spaceService,
|
||||
jwtSecret: jwtSecret,
|
||||
|
|
@ -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,18 +340,21 @@ 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.Name = &name
|
||||
err = s.userRepository.Update(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update 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)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("onboarding completed", "user_id", user.ID, "name", name)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
package expense
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type AddExpenseFormProps struct {
|
||||
Space *model.Space
|
||||
Tags []*model.Tag
|
||||
ListsWithItems []model.ListWithUncheckedItems
|
||||
PaymentMethods []*model.PaymentMethod
|
||||
DialogID string // which dialog to close on success
|
||||
RedirectURL string // when set, server returns HX-Redirect instead of inline swap
|
||||
}
|
||||
|
||||
func (p AddExpenseFormProps) formAttrs() templ.Attributes {
|
||||
attrs := templ.Attributes{
|
||||
"hx-post": "/app/spaces/" + p.Space.ID + "/expenses",
|
||||
}
|
||||
if p.RedirectURL != "" {
|
||||
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') end"
|
||||
} else {
|
||||
attrs["hx-target"] = "#expenses-list-wrapper"
|
||||
attrs["hx-swap"] = "innerHTML"
|
||||
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') then reset() me then show #item-selector-section end"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
templ AddExpenseForm(props AddExpenseFormProps) {
|
||||
<form
|
||||
class="space-y-4"
|
||||
{ props.formAttrs()... }
|
||||
>
|
||||
@csrf.Token()
|
||||
if props.RedirectURL != "" {
|
||||
<input type="hidden" name="redirect" value={ props.RedirectURL }/>
|
||||
}
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "expense-type-expense",
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: true,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click show #item-selector-section",
|
||||
},
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{
|
||||
For: "expense-type-expense",
|
||||
}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "expense-type-topup",
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click hide #item-selector-section",
|
||||
},
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{
|
||||
For: "expense-type-topup",
|
||||
}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "description",
|
||||
}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "description",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "amount",
|
||||
}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "amount",
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Date
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "date",
|
||||
}) {
|
||||
Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "date",
|
||||
Name: "date",
|
||||
Clearable: true,
|
||||
Required: true,
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "new-expense-tags"}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "new-expense-tags",
|
||||
Name: "tags",
|
||||
Tags: props.Tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(props.PaymentMethods, nil)
|
||||
// Shopping list items selector
|
||||
@ItemSelectorSection(props.ListsWithItems, false)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||
for i, t := range exp.Tags {
|
||||
{{ tagValues[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
|
||||
hx-target={ "#expense-" + exp.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-expense-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: exp.Type == model.ExpenseTypeExpense,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-topup-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Checked: exp.Type == model.ExpenseTypeTopup,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "edit-description-" + exp.ID,
|
||||
Value: exp.Description,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-amount-" + exp.ID,
|
||||
Type: "number",
|
||||
Value: model.FormatDecimal(exp.Amount),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
|
||||
Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-date-" + exp.ID,
|
||||
Name: "date",
|
||||
Value: exp.Date,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-tags-" + exp.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, exp.PaymentMethodID)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) {
|
||||
<div
|
||||
id="item-selector-section"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
@label.Label(label.Props{}) {
|
||||
Link Shopping List Items
|
||||
}
|
||||
if len(listsWithItems) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No unchecked items available.</p>
|
||||
} else {
|
||||
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2">
|
||||
for i, lwi := range listsWithItems {
|
||||
{{ toggleID := "toggle-list-" + lwi.List.ID }}
|
||||
{{ itemsID := "items-" + lwi.List.ID }}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "select-all-" + lwi.List.ID,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on change repeat for cb in <input[name='item_ids']/> in #" + itemsID + " set cb.checked to my.checked end",
|
||||
},
|
||||
})
|
||||
@button.Button(button.Props{
|
||||
ID: toggleID,
|
||||
Variant: button.VariantGhost,
|
||||
Class: "flex-1 h-auto p-0 justify-start gap-1 text-sm font-medium select-none",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #" + itemsID + " then toggle .rotate-90 on <svg/> in me",
|
||||
},
|
||||
}) {
|
||||
@icon.ChevronRight(icon.Props{Size: 14})
|
||||
{ lwi.List.Name }
|
||||
<span class="text-muted-foreground">
|
||||
({ strconv.Itoa(len(lwi.Items)) })
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div id={ itemsID } class="hidden pl-6 space-y-1">
|
||||
for _, item := range lwi.Items {
|
||||
<div class="flex items-center gap-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-cb-" + item.ID,
|
||||
Name: "item_ids",
|
||||
Value: item.ID,
|
||||
})
|
||||
<label for={ "item-cb-" + item.ID } class="text-sm cursor-pointer select-none">
|
||||
{ item.Name }
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if i < len(listsWithItems) - 1 {
|
||||
<hr class="border-border"/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Post-action radio group
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-sm text-muted-foreground">After linking items:</p>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "item-action-check",
|
||||
Name: "item_action",
|
||||
Value: "check",
|
||||
Checked: true,
|
||||
})
|
||||
@label.Label(label.Props{For: "item-action-check"}) {
|
||||
Mark as checked
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "item-action-delete",
|
||||
Name: "item_action",
|
||||
Value: "delete",
|
||||
})
|
||||
@label.Label(label.Props{For: "item-action-delete"}) {
|
||||
Delete from list
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) {
|
||||
<div
|
||||
id="balance-card"
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<h2 class="text-lg font-semibold">Current Balance</h2>
|
||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(balance) }
|
||||
if allocated.GreaterThan(decimal.Zero) {
|
||||
<span class="text-base font-normal text-muted-foreground">
|
||||
({ model.FormatMoney(allocated) } in accounts)
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
package moneyaccount
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {
|
||||
<div
|
||||
id="accounts-balance-summary"
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<h2 class="text-lg font-semibold mb-2">Balance Summary</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Total Balance</p>
|
||||
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(totalBalance) }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Allocated</p>
|
||||
<p class="text-xl font-bold">
|
||||
{ model.FormatMoney(totalBalance.Sub(availableBalance)) }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Available</p>
|
||||
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(availableBalance) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bool) {
|
||||
{{ editDialogID := "edit-account-" + acct.ID }}
|
||||
{{ delDialogID := "del-account-" + acct.ID }}
|
||||
{{ depositDialogID := "deposit-" + acct.ID }}
|
||||
{{ withdrawDialogID := "withdraw-" + acct.ID }}
|
||||
<div
|
||||
id={ "account-card-" + acct.ID }
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if len(oob) > 0 && oob[0] {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">{ acct.Name }</h3>
|
||||
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(acct.Balance) }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
// Edit
|
||||
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Account
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the account name.
|
||||
}
|
||||
}
|
||||
@EditAccountForm(spaceID, &acct.MoneyAccount, editDialogID)
|
||||
}
|
||||
}
|
||||
// Delete
|
||||
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Account
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ acct.Name }"? All transfers will be removed.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID),
|
||||
"hx-target": "#account-card-" + acct.ID,
|
||||
"hx-swap": "delete",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
// Deposit
|
||||
@dialog.Dialog(dialog.Props{ID: depositDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
|
||||
@icon.ArrowDownToLine(icon.Props{Size: 14})
|
||||
Deposit
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Deposit to { acct.Name }
|
||||
}
|
||||
@dialog.Description() {
|
||||
Move money from your available balance into this account.
|
||||
}
|
||||
}
|
||||
@TransferForm(spaceID, acct.ID, model.TransferDirectionDeposit, depositDialogID)
|
||||
}
|
||||
}
|
||||
// Withdraw
|
||||
@dialog.Dialog(dialog.Props{ID: withdrawDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
|
||||
@icon.ArrowUpFromLine(icon.Props{Size: 14})
|
||||
Withdraw
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Withdraw from { acct.Name }
|
||||
}
|
||||
@dialog.Description() {
|
||||
Move money from this account back to your available balance.
|
||||
}
|
||||
}
|
||||
@TransferForm(spaceID, acct.ID, model.TransferDirectionWithdrawal, withdrawDialogID)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ CreateAccountForm(spaceID string, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/accounts" }
|
||||
hx-target="#accounts-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "account-name"}) {
|
||||
Account Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "account-name",
|
||||
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Savings, Emergency Fund"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string) {
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID) }
|
||||
hx-target={ "#account-card-" + acct.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-account-name-" + acct.ID}) {
|
||||
Account Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "edit-account-name-" + acct.ID,
|
||||
Value: acct.Name,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ TransferForm(spaceID string, accountID string, direction model.TransferDirection, dialogID string) {
|
||||
{{ errorID := "transfer-error-" + accountID + "-" + string(direction) }}
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers", spaceID, accountID) }
|
||||
hx-target={ "#" + errorID }
|
||||
hx-swap="innerHTML"
|
||||
_={ "on transferSuccess from body call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<input type="hidden" name="direction" value={ string(direction) }/>
|
||||
<div>
|
||||
@label.Label(label.Props{For: "transfer-amount-" + accountID + "-" + string(direction)}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "transfer-amount-" + accountID + "-" + string(direction),
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
|
||||
})
|
||||
<p id={ errorID } class="text-sm text-destructive mt-1"></p>
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{For: "transfer-note-" + accountID + "-" + string(direction)}) {
|
||||
Note (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "note",
|
||||
ID: "transfer-note-" + accountID + "-" + string(direction),
|
||||
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
if direction == model.TransferDirectionDeposit {
|
||||
Deposit
|
||||
} else {
|
||||
Withdraw
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
|
||||
<div class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-bold">Transfer History</h2>
|
||||
<div class="border rounded-lg">
|
||||
<div id="transfer-history-wrapper">
|
||||
@TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) {
|
||||
<div
|
||||
if oob {
|
||||
id="transfer-history-wrapper"
|
||||
hx-swap-oob="innerHTML:#transfer-history-wrapper"
|
||||
}
|
||||
>
|
||||
<div class="divide-y">
|
||||
if len(transfers) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No transfers recorded yet.</p>
|
||||
}
|
||||
for _, t := range transfers {
|
||||
@TransferHistoryItem(spaceID, t)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
<div class="border-t p-2">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage-1),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, pg),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage+1),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
|
||||
<div id={ "transfer-" + t.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium">
|
||||
if t.Note != "" {
|
||||
{ t.Note }
|
||||
} else if t.Direction == model.TransferDirectionDeposit {
|
||||
Deposit
|
||||
} else {
|
||||
Withdrawal
|
||||
}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ t.CreatedAt.Format("Jan 2, 2006") } · { t.AccountName }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
if t.Direction == model.TransferDirectionDeposit {
|
||||
<span class="font-bold text-green-600 whitespace-nowrap">
|
||||
+{ model.FormatMoney(t.Amount) }
|
||||
</span>
|
||||
} else {
|
||||
<span class="font-bold text-destructive whitespace-nowrap">
|
||||
-{ model.FormatMoney(t.Amount) }
|
||||
</span>
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers/%s", spaceID, t.AccountID, t.ID),
|
||||
"hx-target": "#transfer-" + t.ID,
|
||||
"hx-swap": "delete",
|
||||
"hx-confirm": "Delete this transfer?",
|
||||
},
|
||||
}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
package paymentmethod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
)
|
||||
|
||||
func methodDisplay(m *model.PaymentMethod) string {
|
||||
upper := strings.ToUpper(string(m.Type))
|
||||
if m.LastFour != nil {
|
||||
return upper + " **** " + *m.LastFour
|
||||
}
|
||||
return upper
|
||||
}
|
||||
|
||||
templ MethodItem(spaceID string, method *model.PaymentMethod) {
|
||||
{{ editDialogID := "edit-method-" + method.ID }}
|
||||
{{ delDialogID := "del-method-" + method.ID }}
|
||||
<div id={ "method-item-" + method.ID } class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">{ method.Name }</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ methodDisplay(method) }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Payment Method
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the payment method details.
|
||||
}
|
||||
}
|
||||
@EditMethodForm(spaceID, method, editDialogID)
|
||||
}
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Payment Method
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ method.Name }"? Existing expenses will keep their data but will no longer show a payment method.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/payment-methods/%s", spaceID, method.ID),
|
||||
"hx-target": "#method-item-" + method.ID,
|
||||
"hx-swap": "delete",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ CreateMethodForm(spaceID string, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/payment-methods" }
|
||||
hx-target="#methods-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "method-name"}) {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "method-name",
|
||||
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Chase Sapphire"},
|
||||
})
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Type
|
||||
}
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "method-type-credit",
|
||||
Name: "type",
|
||||
Value: "credit",
|
||||
Checked: true,
|
||||
})
|
||||
@label.Label(label.Props{For: "method-type-credit"}) {
|
||||
Credit
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "method-type-debit",
|
||||
Name: "type",
|
||||
Value: "debit",
|
||||
})
|
||||
@label.Label(label.Props{For: "method-type-debit"}) {
|
||||
Debit
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="last-four-group">
|
||||
@label.Label(label.Props{For: "method-last-four"}) {
|
||||
Last 4 Digits
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "last_four",
|
||||
ID: "method-last-four",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
"maxlength": "4",
|
||||
"minlength": "4",
|
||||
"pattern": "[0-9]{4}",
|
||||
"placeholder": "1234",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditMethodForm(spaceID string, method *model.PaymentMethod, dialogID string) {
|
||||
{{ lastFourVal := "" }}
|
||||
if method.LastFour != nil {
|
||||
{{ lastFourVal = *method.LastFour }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/payment-methods/%s", spaceID, method.ID) }
|
||||
hx-target={ "#method-item-" + method.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-method-name-" + method.ID}) {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "edit-method-name-" + method.ID,
|
||||
Value: method.Name,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Type
|
||||
}
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-method-type-credit-" + method.ID,
|
||||
Name: "type",
|
||||
Value: "credit",
|
||||
Checked: method.Type == model.PaymentMethodTypeCredit,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-method-type-credit-" + method.ID}) {
|
||||
Credit
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-method-type-debit-" + method.ID,
|
||||
Name: "type",
|
||||
Value: "debit",
|
||||
Checked: method.Type == model.PaymentMethodTypeDebit,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-method-type-debit-" + method.ID}) {
|
||||
Debit
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "edit-last-four-group-" + method.ID }>
|
||||
@label.Label(label.Props{For: "edit-method-last-four-" + method.ID}) {
|
||||
Last 4 Digits
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "last_four",
|
||||
ID: "edit-method-last-four-" + method.ID,
|
||||
Value: lastFourVal,
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
"maxlength": "4",
|
||||
"minlength": "4",
|
||||
"pattern": "[0-9]{4}",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ MethodSelector(methods []*model.PaymentMethod, selectedMethodID *string) {
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Payment Method
|
||||
}
|
||||
@selectbox.SelectBox() {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "payment_method_id"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Cash"})
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: len(methods) <= 5}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "", Selected: selectedMethodID == nil}) {
|
||||
Cash
|
||||
}
|
||||
for _, m := range methods {
|
||||
if m.LastFour != nil {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
|
||||
{ m.Name } (*{ *m.LastFour })
|
||||
}
|
||||
} else {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
|
||||
{ m.Name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
package recurring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
)
|
||||
|
||||
func frequencyLabel(f model.Frequency) string {
|
||||
switch f {
|
||||
case model.FrequencyDaily:
|
||||
return "Daily"
|
||||
case model.FrequencyWeekly:
|
||||
return "Weekly"
|
||||
case model.FrequencyBiweekly:
|
||||
return "Biweekly"
|
||||
case model.FrequencyMonthly:
|
||||
return "Monthly"
|
||||
case model.FrequencyYearly:
|
||||
return "Yearly"
|
||||
default:
|
||||
return string(f)
|
||||
}
|
||||
}
|
||||
|
||||
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ delDialogID := "del-recurring-" + re.ID }}
|
||||
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{ re.Description }</p>
|
||||
if !re.IsActive {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
Paused
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
|
||||
{ frequencyLabel(re.Frequency) }
|
||||
}
|
||||
<span>Next: { re.NextOccurrence.Format("Jan 02, 2006") }</span>
|
||||
if re.PaymentMethod != nil {
|
||||
if re.PaymentMethod.LastFour != nil {
|
||||
<span>· { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
|
||||
} else {
|
||||
<span>· { re.PaymentMethod.Name }</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if len(re.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
for _, t := range re.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
if re.Type == model.ExpenseTypeExpense {
|
||||
<p class="font-bold text-destructive">
|
||||
- { model.FormatMoney(re.Amount) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="font-bold text-green-500">
|
||||
+ { model.FormatMoney(re.Amount) }
|
||||
</p>
|
||||
}
|
||||
// Toggle pause/resume
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-post": fmt.Sprintf("/app/spaces/%s/recurring/%s/toggle", spaceID, re.ID),
|
||||
"hx-target": "#recurring-" + re.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
if re.IsActive {
|
||||
@icon.Pause(icon.Props{Size: 14})
|
||||
} else {
|
||||
@icon.Play(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
// Edit button
|
||||
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Recurring Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the details of this recurring transaction.
|
||||
}
|
||||
}
|
||||
@EditRecurringForm(spaceID, re, methods, tags)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Recurring Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ re.Description }"? This will not remove previously generated expenses.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID),
|
||||
"hx-target": "#recurring-" + re.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.PaymentMethod, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/recurring" }
|
||||
hx-target="#recurring-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "recurring-type-expense",
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: true,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "recurring-type-expense"}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "recurring-type-topup",
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "recurring-type-topup"}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-description"}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "recurring-description",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-amount"}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "recurring-amount",
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Frequency
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Frequency
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "recurring-frequency"}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
|
||||
@selectbox.Value()
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "daily"}) {
|
||||
Daily
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "weekly"}) {
|
||||
Weekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "biweekly"}) {
|
||||
Biweekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: true}) {
|
||||
Monthly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "yearly"}) {
|
||||
Yearly
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-start-date"}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "recurring-start-date",
|
||||
Name: "start_date",
|
||||
Required: true,
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// End Date (optional)
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-end-date"}) {
|
||||
End Date (optional)
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "recurring-end-date",
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-tags"}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "recurring-tags",
|
||||
Name: "tags",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, nil)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ tagValues := make([]string, len(re.Tags)) }}
|
||||
for i, t := range re.Tags {
|
||||
{{ tagValues[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID) }
|
||||
hx-target={ "#recurring-" + re.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-recurring-type-expense-" + re.ID,
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: re.Type == model.ExpenseTypeExpense,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-recurring-type-expense-" + re.ID}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-recurring-type-topup-" + re.ID,
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Checked: re.Type == model.ExpenseTypeTopup,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-recurring-type-topup-" + re.ID}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-desc-" + re.ID}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "edit-recurring-desc-" + re.ID,
|
||||
Value: re.Description,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-amount-" + re.ID}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-recurring-amount-" + re.ID,
|
||||
Type: "number",
|
||||
Value: model.FormatDecimal(re.Amount),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Frequency
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Frequency
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "edit-recurring-freq-" + re.ID}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
|
||||
@selectbox.Value()
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "daily", Selected: re.Frequency == model.FrequencyDaily}) {
|
||||
Daily
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "weekly", Selected: re.Frequency == model.FrequencyWeekly}) {
|
||||
Weekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "biweekly", Selected: re.Frequency == model.FrequencyBiweekly}) {
|
||||
Biweekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: re.Frequency == model.FrequencyMonthly}) {
|
||||
Monthly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "yearly", Selected: re.Frequency == model.FrequencyYearly}) {
|
||||
Yearly
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-start-" + re.ID}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-start-" + re.ID,
|
||||
Name: "start_date",
|
||||
Value: re.StartDate,
|
||||
Required: true,
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// End Date (optional)
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-end-" + re.ID}) {
|
||||
End Date (optional)
|
||||
}
|
||||
if re.EndDate != nil {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-end-" + re.ID,
|
||||
Name: "end_date",
|
||||
Value: *re.EndDate,
|
||||
Clearable: true,
|
||||
})
|
||||
} else {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-end-" + re.ID,
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-recurring-tags-" + re.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
package shoppinglist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
)
|
||||
|
||||
// ListCard renders a full shopping list card with inline items, add form, and pagination.
|
||||
templ ListCard(spaceID string, list *model.ShoppingList, items []*model.ListItem, currentPage, totalPages int) {
|
||||
<div id={ "list-card-" + list.ID } class="border rounded-lg overflow-hidden flex flex-col">
|
||||
<div class="p-4 border-b bg-muted/30">
|
||||
@ListCardHeader(spaceID, list)
|
||||
</div>
|
||||
<div class="p-3 border-b">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/lists/%s/items", spaceID, list.ID) }
|
||||
hx-swap="none"
|
||||
_={ fmt.Sprintf("on htmx:afterRequest if event.detail.successful reset() me then send refreshItems to #list-items-%s", list.ID) }
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Placeholder: "Add item...",
|
||||
Class: "h-8 text-sm",
|
||||
Attributes: templ.Attributes{
|
||||
"autocomplete": "off",
|
||||
},
|
||||
})
|
||||
@button.Submit(button.Props{
|
||||
Size: button.SizeSm,
|
||||
}) {
|
||||
@icon.Plus(icon.Props{Size: 16})
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
id={ "list-items-" + list.ID }
|
||||
hx-get={ fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=1", spaceID, list.ID) }
|
||||
hx-trigger="refreshItems"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@ListCardItems(spaceID, list.ID, items, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ListCardHeader renders the card header with name display, edit form, and delete button.
|
||||
templ ListCardHeader(spaceID string, list *model.ShoppingList) {
|
||||
<div id={ "lch-" + list.ID } class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-semibold truncate">{ list.Name }</h3>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-foreground",
|
||||
Attributes: templ.Attributes{
|
||||
"_": fmt.Sprintf("on click toggle .hidden on #lch-%s then toggle .hidden on #lche-%s then focus() the first <input/> in #lche-%s", list.ID, list.ID, list.ID),
|
||||
},
|
||||
}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{ID: "del-list-" + list.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-destructive",
|
||||
}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Shopping List
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s?from=card", spaceID, list.ID),
|
||||
"hx-target": "#list-card-" + list.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id={ "lche-" + list.ID }
|
||||
class="hidden flex items-center gap-2"
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s?from=card", spaceID, list.ID) }
|
||||
hx-target={ "#lch-" + list.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ fmt.Sprintf("on htmx:afterRequest toggle .hidden on me then toggle .hidden on #lch-%s", list.ID) }
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Value: list.Name,
|
||||
Class: "h-8 text-sm",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
},
|
||||
})
|
||||
@button.Submit(button.Props{Size: button.SizeSm}) {
|
||||
Save
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Size: button.SizeSm,
|
||||
Attributes: templ.Attributes{
|
||||
"_": fmt.Sprintf("on click toggle .hidden on #lche-%s then toggle .hidden on #lch-%s", list.ID, list.ID),
|
||||
},
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
// ListCardItems renders the paginated items section within a card.
|
||||
templ ListCardItems(spaceID string, listID string, items []*model.ListItem, currentPage, totalPages int) {
|
||||
if len(items) == 0 {
|
||||
<p class="text-center text-muted-foreground p-6 text-sm">No items yet</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for _, item := range items {
|
||||
@CardItemDetail(spaceID, item)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if totalPages > 1 {
|
||||
<div class="border-t p-2">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage-1),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, pg),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage+1),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// CardItemDetail renders an item within a card. Toggle is in-place, delete triggers a refresh.
|
||||
templ CardItemDetail(spaceID string, item *model.ListItem) {
|
||||
<div id={ "item-" + item.ID } class="flex items-center gap-2 px-4 py-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-" + item.ID + "-checkbox",
|
||||
Name: "is_checked",
|
||||
Checked: item.IsChecked,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s?from=card", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
<span class={ "text-sm flex-1", templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-destructive shrink-0",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-swap": "none",
|
||||
"_": fmt.Sprintf("on htmx:afterRequest send refreshItems to #list-items-%s", item.ListID),
|
||||
},
|
||||
}) {
|
||||
@icon.X(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ListNameHeader is used on the detail page for editing list name inline.
|
||||
templ ListNameHeader(spaceID string, list *model.ShoppingList) {
|
||||
<div id="list-name-header" class="flex items-center gap-2 group">
|
||||
<h1 class="text-2xl font-bold">{ list.Name }</h1>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #list-name-header then toggle .hidden on #list-name-edit then focus() the first <input/> in #list-name-edit",
|
||||
},
|
||||
}) {
|
||||
@icon.Pencil(icon.Props{Size: 16})
|
||||
}
|
||||
</div>
|
||||
<form
|
||||
id="list-name-edit"
|
||||
class="hidden flex items-center gap-2"
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s", spaceID, list.ID) }
|
||||
hx-target="#list-name-header"
|
||||
hx-swap="outerHTML"
|
||||
_="on htmx:afterRequest toggle .hidden on me then toggle .hidden on #list-name-header"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Value: list.Name,
|
||||
Class: "max-w-xs",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #list-name-edit then toggle .hidden on #list-name-header",
|
||||
},
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
// ItemDetail renders an individual item row (used by the detail page and toggle responses).
|
||||
templ ItemDetail(spaceID string, item *model.ListItem) {
|
||||
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-" + item.ID + "-checkbox",
|
||||
Name: "is_checked",
|
||||
Checked: item.IsChecked,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
<span class={ templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "ml-auto size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
@icon.X(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package tag
|
||||
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
|
||||
templ Tag(tag *model.Tag) {
|
||||
<div
|
||||
id={ "tag-" + tag.ID }
|
||||
class="flex items-center gap-2 rounded-full border px-3 py-1 text-sm"
|
||||
>
|
||||
if tag.Color != nil {
|
||||
<span class="size-3 rounded-full" style={ "background-color: " + *tag.Color }></span>
|
||||
}
|
||||
<span>{ tag.Name }</span>
|
||||
<button
|
||||
hx-delete={ "/app/spaces/" + tag.SpaceID + "/tags/" + tag.ID }
|
||||
hx-target={ "#tag-" + tag.ID }
|
||||
hx-swap="outerHTML"
|
||||
class="ml-auto text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
package tagcombobox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/utils"
|
||||
)
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Name string // form field name (default "tags")
|
||||
Value []string // pre-selected tag names
|
||||
Tags []*model.Tag // all available tags in the space
|
||||
Placeholder string
|
||||
HasError bool
|
||||
Disabled bool
|
||||
Form string // associate with external form
|
||||
}
|
||||
|
||||
func (p Props) fieldName() string {
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
return "tags"
|
||||
}
|
||||
|
||||
func (p Props) isSelected(tagName string) bool {
|
||||
lower := strings.ToLower(tagName)
|
||||
for _, v := range p.Value {
|
||||
if strings.ToLower(v) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
templ TagCombobox(props Props) {
|
||||
<div
|
||||
id={ props.ID + "-container" }
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"relative w-full",
|
||||
),
|
||||
}
|
||||
data-tagcombobox
|
||||
data-tagcombobox-name={ props.fieldName() }
|
||||
data-tagcombobox-form={ props.Form }
|
||||
>
|
||||
// Main input area styled like tagsinput
|
||||
<div
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none cursor-text",
|
||||
"dark:bg-input/30",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
utils.If(props.Disabled, "opacity-50 cursor-not-allowed"),
|
||||
"w-full min-h-[38px]",
|
||||
utils.If(props.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
|
||||
),
|
||||
}
|
||||
data-tagcombobox-input-area
|
||||
>
|
||||
// Selected tag chips
|
||||
<div class="flex items-center flex-wrap gap-2" data-tagcombobox-chips>
|
||||
for _, val := range props.Value {
|
||||
@badge.Badge(badge.Props{
|
||||
Attributes: templ.Attributes{"data-tagcombobox-chip": val},
|
||||
}) {
|
||||
<span>{ val }</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
disabled?={ props.Disabled }
|
||||
data-tagcombobox-remove={ val }
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Text input for searching/typing
|
||||
<input
|
||||
type="text"
|
||||
id={ props.ID }
|
||||
class="border-0 shadow-none focus-visible:ring-0 focus-visible:outline-none h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent flex-1 min-w-[80px] text-sm"
|
||||
placeholder={ props.Placeholder }
|
||||
disabled?={ props.Disabled }
|
||||
autocomplete="off"
|
||||
data-tagcombobox-text-input
|
||||
/>
|
||||
</div>
|
||||
// Dropdown
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md hidden max-h-60 overflow-y-auto"
|
||||
data-tagcombobox-dropdown
|
||||
>
|
||||
for _, tag := range props.Tags {
|
||||
<div
|
||||
class={
|
||||
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
templ.KV("font-medium", props.isSelected(tag.Name)),
|
||||
}
|
||||
data-tagcombobox-item={ tag.Name }
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={
|
||||
"h-4 w-4 shrink-0",
|
||||
templ.KV("opacity-100", props.isSelected(tag.Name)),
|
||||
templ.KV("opacity-0", !props.isSelected(tag.Name)),
|
||||
}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
data-tagcombobox-check
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
if tag.Color != nil {
|
||||
<span class="inline-block w-3 h-3 rounded-full shrink-0" style={ "background-color: " + *tag.Color }></span>
|
||||
}
|
||||
<span data-tagcombobox-item-label>{ tag.Name }</span>
|
||||
</div>
|
||||
}
|
||||
// "Create new" option (hidden by default, shown when typing something new)
|
||||
<div
|
||||
class="hidden items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground text-muted-foreground"
|
||||
data-tagcombobox-create
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span>Create "<span data-tagcombobox-create-label></span>"</span>
|
||||
</div>
|
||||
</div>
|
||||
// Hidden inputs for form submission
|
||||
<div data-tagcombobox-hidden-inputs>
|
||||
for _, val := range props.Value {
|
||||
<input
|
||||
type="hidden"
|
||||
name={ props.fieldName() }
|
||||
value={ val }
|
||||
if props.Form != "" {
|
||||
form={ props.Form }
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Script() {
|
||||
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/tagcombobox.js") }></script>
|
||||
}
|
||||
|
|
@ -75,9 +75,8 @@ templ App(title string) {
|
|||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
{{ user := ctxkeys.User(ctx) }}
|
||||
{{ profile := ctxkeys.Profile(ctx) }}
|
||||
if user != nil && profile != nil {
|
||||
@AppSidebarDropdown(user, profile)
|
||||
if user != nil {
|
||||
@AppSidebarDropdown(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,7 +112,9 @@ templ App(title string) {
|
|||
}
|
||||
}
|
||||
|
||||
templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
||||
templ AppSidebarDropdown(user *model.User) {
|
||||
{{ displayName := user.Email }}
|
||||
{{ if user.Name != nil && *user.Name != "" { displayName = *user.Name } }}
|
||||
@dropdown.Dropdown() {
|
||||
@dropdown.Trigger() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
|
|
@ -121,16 +122,13 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
|||
}) {
|
||||
<div id="sidebar-avatar" hx-swap-oob="true">
|
||||
@avatar.Avatar(avatar.Props{Class: "size-8 rounded-lg"}) {
|
||||
<!-- if user.AvatarURL != "" { -->
|
||||
<!-- @avatar.Image(avatar.ImageProps{Src: user.AvatarURL, Alt: profile.Name}) -->
|
||||
<!-- } -->
|
||||
@avatar.Fallback() {
|
||||
{ strings.ToUpper(string(profile.Name[0])) }
|
||||
{ strings.ToUpper(string(displayName[0])) }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div id="sidebar-user-name" hx-swap-oob="true" class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{ profile.Name }</span>
|
||||
<span class="truncate font-medium">{ displayName }</span>
|
||||
<span class="truncate text-xs text-muted-foreground">{ user.Email }</span>
|
||||
</div>
|
||||
@icon.ChevronsUpDown(icon.Props{Class: "ml-auto size-4"})
|
||||
|
|
@ -143,7 +141,7 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
|||
<div id="dropdown-user-label" hx-swap-oob="true">
|
||||
@dropdown.Label() {
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{ profile.Name }</span>
|
||||
<span class="font-medium">{ displayName }</span>
|
||||
<span class="text-xs text-muted-foreground">{ user.Email }</span>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
|||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ templ Base(props ...SEOProps) {
|
|||
@progress.Script()
|
||||
@tagsinput.Script()
|
||||
@selectbox.Script()
|
||||
@tagcombobox.Script()
|
||||
|
||||
// Site-wide enhancements
|
||||
@themeScript()
|
||||
// Must run before body to prevent flash
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ Space(title string, space *model.Space) {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
@Base(SEOProps{
|
||||
Title: title,
|
||||
Description: "Space Dashboard",
|
||||
Path: ctxkeys.URLPath(ctx),
|
||||
}) {
|
||||
@sidebar.Layout() {
|
||||
@sidebar.Sidebar() {
|
||||
@sidebar.Header() {
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Size: sidebar.MenuButtonSizeLg,
|
||||
Href: "/app/spaces",
|
||||
}) {
|
||||
@icon.LayoutDashboard()
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold">{ cfg.AppName }</span>
|
||||
<span class="text-xs text-muted-foreground">Back to Spaces</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Content() {
|
||||
@sidebar.Group() {
|
||||
@sidebar.GroupLabel() {
|
||||
{ space.Name }
|
||||
}
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID,
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
|
||||
Tooltip: "Overview",
|
||||
}) {
|
||||
@icon.House(icon.Props{Class: "size-4"})
|
||||
<span>Overview</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/reports",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/reports",
|
||||
Tooltip: "Reports",
|
||||
}) {
|
||||
@icon.ChartPie(icon.Props{Class: "size-4"})
|
||||
<span>Reports</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/expenses",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/expenses",
|
||||
Tooltip: "Expenses",
|
||||
}) {
|
||||
@icon.DollarSign(icon.Props{Class: "size-4"})
|
||||
<span>Expenses</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/recurring",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/recurring",
|
||||
Tooltip: "Recurring Transactions",
|
||||
}) {
|
||||
@icon.Repeat(icon.Props{Class: "size-4"})
|
||||
<span>Recurring</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/budgets",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/budgets",
|
||||
Tooltip: "Budgets",
|
||||
}) {
|
||||
@icon.Target(icon.Props{Class: "size-4"})
|
||||
<span>Budgets</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/loans",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/loans" || strings.HasPrefix(ctxkeys.URLPath(ctx), "/app/spaces/"+space.ID+"/loans/"),
|
||||
Tooltip: "Loans",
|
||||
}) {
|
||||
@icon.Landmark(icon.Props{Class: "size-4"})
|
||||
<span>Loans</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/accounts",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/accounts",
|
||||
Tooltip: "Money Accounts",
|
||||
}) {
|
||||
@icon.PiggyBank(icon.Props{Class: "size-4"})
|
||||
<span>Accounts</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/payment-methods",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/payment-methods",
|
||||
Tooltip: "Payment Methods",
|
||||
}) {
|
||||
@icon.CreditCard(icon.Props{Class: "size-4"})
|
||||
<span>Payment Methods</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/lists",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/lists",
|
||||
Tooltip: "Shopping Lists",
|
||||
}) {
|
||||
@icon.ShoppingCart(icon.Props{Class: "size-4"})
|
||||
<span>Shopping Lists</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/tags",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/tags",
|
||||
Tooltip: "Tags",
|
||||
}) {
|
||||
@icon.Tag(icon.Props{Class: "size-4"})
|
||||
<span>Tags</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/settings",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings",
|
||||
Tooltip: "Settings",
|
||||
}) {
|
||||
@icon.Settings(icon.Props{Class: "size-4"})
|
||||
<span>Settings</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Footer() {
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "mailto:" + cfg.SupportEmail,
|
||||
Size: sidebar.MenuButtonSizeSm,
|
||||
}) {
|
||||
@icon.MessageCircleQuestionMark(icon.Props{Class: "size-4"})
|
||||
<span>Support</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
<div class="px-2 py-1">
|
||||
<span class="text-xs text-muted-foreground">App version: { cfg.Version }</span>
|
||||
</div>
|
||||
@sidebar.Separator()
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
{{ user := ctxkeys.User(ctx) }}
|
||||
{{ profile := ctxkeys.Profile(ctx) }}
|
||||
if user != nil && profile != nil {
|
||||
@AppSidebarDropdown(user, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Inset() {
|
||||
<div class="flex flex-col h-full">
|
||||
// Top Navigation Bar
|
||||
<header class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex h-14 items-center px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
@sidebar.Trigger()
|
||||
@breadcrumb.Breadcrumb() {
|
||||
@breadcrumb.List() {
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces"}) {
|
||||
Spaces
|
||||
}
|
||||
}
|
||||
@breadcrumb.Separator()
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
|
||||
{ space.Name }
|
||||
}
|
||||
}
|
||||
@breadcrumb.Separator()
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Page() {
|
||||
{ title }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
@blocks.ThemeSwitcher()
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
// App Content
|
||||
<main class="flex-1 p-6">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
)
|
||||
|
||||
templ Dashboard(spaces []*model.Space) {
|
||||
@layouts.App("Spaces") {
|
||||
<div class="container max-w-7xl px-6 py-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Spaces</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Welcome back!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
for _, space := range spaces {
|
||||
<a href={ templ.SafeURL("/app/spaces/" + space.ID) } class="block hover:no-underline group">
|
||||
@card.Card(card.Props{Class: "h-full transition-colors group-hover:border-primary"}) {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
{ space.Name }
|
||||
}
|
||||
@card.Description() {
|
||||
Manage expenses in this space.
|
||||
}
|
||||
}
|
||||
@card.Content()
|
||||
}
|
||||
</a>
|
||||
}
|
||||
// Option to create a new space
|
||||
@dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@card.Card(card.Props{Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary"}) {
|
||||
@card.Content(card.ContentProps{Class: "h-full flex flex-col items-center justify-center py-12"}) {
|
||||
@icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"})
|
||||
<p class="text-muted-foreground">Create a new space</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Create Space
|
||||
}
|
||||
@dialog.Description() {
|
||||
Create a new space to organize expenses and more.
|
||||
}
|
||||
}
|
||||
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">
|
||||
@csrf.Token()
|
||||
<div class="space-y-2">
|
||||
@label.Label(label.Props{For: "space-name"}) {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
ID: "space-name",
|
||||
Name: "name",
|
||||
Type: input.TypeText,
|
||||
Placeholder: "e.g. Household, Trip, Roommates",
|
||||
Attributes: templ.Attributes{
|
||||
"describedby": "create-space-error",
|
||||
},
|
||||
})
|
||||
<p id="create-space-error" class="text-sm text-destructive"></p>
|
||||
</div>
|
||||
@dialog.Footer() {
|
||||
@dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/timezone"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
|
|
@ -9,55 +8,15 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
)
|
||||
|
||||
templ AppSettings(hasPassword bool, errorMsg string, currentTimezone string) {
|
||||
templ AppSettings(hasPassword bool, errorMsg string) {
|
||||
@layouts.App("Settings") {
|
||||
<div class="container max-w-2xl px-6 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Settings</h1>
|
||||
<p class="text-muted-foreground mt-2">Manage your account settings</p>
|
||||
</div>
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
Timezone
|
||||
}
|
||||
@card.Description() {
|
||||
Set your timezone for recurring expenses and reports
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<form action="/app/settings/timezone" method="POST" class="space-y-4">
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@label.Label(label.Props{
|
||||
For: "timezone",
|
||||
Class: "block mb-2",
|
||||
}) {
|
||||
Timezone
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "timezone-select"}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
|
||||
for _, tz := range timezone.CommonTimezones() {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: tz.Value == currentTimezone}) {
|
||||
{ tz.Label }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@button.Submit() {
|
||||
Update Timezone
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
<div class="mt-6"></div>
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance decimal.Decimal, availableBalance decimal.Decimal, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
|
||||
@layouts.Space("Accounts", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Money Accounts</h1>
|
||||
@dialog.Dialog(dialog.Props{ID: "add-account-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button() {
|
||||
New Account
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Create Account
|
||||
}
|
||||
@dialog.Description() {
|
||||
Create a new money account to set aside funds.
|
||||
}
|
||||
}
|
||||
@moneyaccount.CreateAccountForm(space.ID, "add-account-dialog")
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@moneyaccount.BalanceSummaryCard(space.ID, totalBalance, availableBalance, false)
|
||||
<div id="accounts-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
if len(accounts) == 0 {
|
||||
<p class="text-sm text-muted-foreground col-span-full">No money accounts yet. Create one to start allocating funds.</p>
|
||||
}
|
||||
for _, acct := range accounts {
|
||||
@moneyaccount.AccountCard(space.ID, &acct)
|
||||
}
|
||||
</div>
|
||||
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
func periodLabel(p model.BudgetPeriod) string {
|
||||
switch p {
|
||||
case model.BudgetPeriodWeekly:
|
||||
return "Weekly"
|
||||
case model.BudgetPeriodYearly:
|
||||
return "Yearly"
|
||||
default:
|
||||
return "Monthly"
|
||||
}
|
||||
}
|
||||
|
||||
func progressBarColor(status model.BudgetStatus) string {
|
||||
switch status {
|
||||
case model.BudgetStatusOver:
|
||||
return "bg-destructive"
|
||||
case model.BudgetStatusWarning:
|
||||
return "bg-yellow-500"
|
||||
default:
|
||||
return "bg-green-500"
|
||||
}
|
||||
}
|
||||
|
||||
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
|
||||
@layouts.Space("Budgets", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Budgets</h1>
|
||||
@dialog.Dialog(dialog.Props{ID: "add-budget-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button() {
|
||||
Add Budget
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Add Budget
|
||||
}
|
||||
@dialog.Description() {
|
||||
Set a spending limit for one or more tag categories.
|
||||
}
|
||||
}
|
||||
@AddBudgetForm(space.ID, tags)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div id="budgets-list-wrapper">
|
||||
@BudgetsList(space.ID, budgets, tags)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ BudgetsList(spaceID string, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
|
||||
<div id="budgets-list" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
if len(budgets) == 0 {
|
||||
<p class="text-sm text-muted-foreground col-span-full">No budgets set up yet.</p>
|
||||
}
|
||||
for _, b := range budgets {
|
||||
@BudgetCard(spaceID, b, tags)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-budget-" + b.ID }}
|
||||
{{ delDialogID := "del-budget-" + b.ID }}
|
||||
{{ pct := b.Percentage }}
|
||||
if pct > 100 {
|
||||
{{ pct = 100 }}
|
||||
}
|
||||
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
for _, t := range b.Tags {
|
||||
<span class="inline-flex items-center gap-1">
|
||||
if t.Color != nil {
|
||||
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *t.Color }></span>
|
||||
}
|
||||
<span class="text-sm font-semibold">{ t.Name }</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Budget
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update this budget's settings.
|
||||
}
|
||||
}
|
||||
@EditBudgetForm(spaceID, b, tags)
|
||||
}
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Budget
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete this budget?
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID),
|
||||
"hx-target": "#budget-" + b.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
// Progress bar
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>{ model.FormatMoney(b.Spent) } spent</span>
|
||||
<span>of { model.FormatMoney(b.Amount) }</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2.5">
|
||||
<div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
|
||||
</div>
|
||||
if b.Status == model.BudgetStatusOver {
|
||||
<p class="text-xs text-destructive font-medium">Over budget by { model.FormatMoney(b.Spent.Sub(b.Amount)) }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AddBudgetForm(spaceID string, tags []*model.Tag) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/budgets" }
|
||||
hx-target="#budgets-list-wrapper"
|
||||
hx-swap="innerHTML"
|
||||
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('add-budget-dialog') then reset() me end"
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Tag selector
|
||||
<div>
|
||||
@label.Label(label.Props{For: "budget-tags"}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "budget-tags",
|
||||
Name: "tags",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "budget-amount"}) {
|
||||
Budget Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "budget-amount",
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Period
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Period
|
||||
}
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "budget-period-monthly",
|
||||
Name: "period",
|
||||
Value: "monthly",
|
||||
Checked: true,
|
||||
})
|
||||
@label.Label(label.Props{For: "budget-period-monthly"}) {
|
||||
Monthly
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "budget-period-weekly",
|
||||
Name: "period",
|
||||
Value: "weekly",
|
||||
})
|
||||
@label.Label(label.Props{For: "budget-period-weekly"}) {
|
||||
Weekly
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "budget-period-yearly",
|
||||
Name: "period",
|
||||
Value: "yearly",
|
||||
})
|
||||
@label.Label(label.Props{For: "budget-period-yearly"}) {
|
||||
Yearly
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "budget-start-date"}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "budget-start-date",
|
||||
Name: "start_date",
|
||||
Required: true,
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// End Date (optional)
|
||||
<div>
|
||||
@label.Label(label.Props{For: "budget-end-date"}) {
|
||||
End Date (optional)
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "budget-end-date",
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-budget-" + b.ID }}
|
||||
{{ budgetTagNames := make([]string, len(b.Tags)) }}
|
||||
for i, t := range b.Tags {
|
||||
{{ budgetTagNames[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
|
||||
hx-target="#budgets-list-wrapper"
|
||||
hx-swap="innerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Tag selector
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-budget-tags-" + b.ID}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-budget-tags-" + b.ID,
|
||||
Name: "tags",
|
||||
Value: budgetTagNames,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-budget-amount-" + b.ID}) {
|
||||
Budget Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-budget-amount-" + b.ID,
|
||||
Type: "number",
|
||||
Value: model.FormatDecimal(b.Amount),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Period
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Period
|
||||
}
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-budget-period-monthly-" + b.ID,
|
||||
Name: "period",
|
||||
Value: "monthly",
|
||||
Checked: b.Period == model.BudgetPeriodMonthly,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-budget-period-monthly-" + b.ID}) {
|
||||
Monthly
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-budget-period-weekly-" + b.ID,
|
||||
Name: "period",
|
||||
Value: "weekly",
|
||||
Checked: b.Period == model.BudgetPeriodWeekly,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-budget-period-weekly-" + b.ID}) {
|
||||
Weekly
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-budget-period-yearly-" + b.ID,
|
||||
Name: "period",
|
||||
Value: "yearly",
|
||||
Checked: b.Period == model.BudgetPeriodYearly,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-budget-period-yearly-" + b.ID}) {
|
||||
Yearly
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-budget-start-" + b.ID}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-budget-start-" + b.ID,
|
||||
Name: "start_date",
|
||||
Value: b.StartDate,
|
||||
Clearable: true,
|
||||
Required: true,
|
||||
})
|
||||
</div>
|
||||
// End Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-budget-end-" + b.ID}) {
|
||||
End Date (optional)
|
||||
}
|
||||
if b.EndDate != nil {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-budget-end-" + b.ID,
|
||||
Name: "end_date",
|
||||
Value: *b.EndDate,
|
||||
Clearable: true,
|
||||
})
|
||||
} else {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-budget-end-" + b.ID,
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
|
||||
)
|
||||
|
||||
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) {
|
||||
@layouts.Space("Expenses", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Expenses</h1>
|
||||
@dialogs.AddTransaction(space, tags, listsWithItems, methods)
|
||||
</div>
|
||||
// Balance Card
|
||||
@expense.BalanceCard(space.ID, balance, allocated, false)
|
||||
// List of expenses
|
||||
<div class="border rounded-lg">
|
||||
<div id="expenses-list-wrapper">
|
||||
@ExpensesListContent(space.ID, expenses, methods, tags, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag, currentPage, totalPages int) {
|
||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||
<div id="expenses-list" class="divide-y">
|
||||
if len(expenses) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
||||
}
|
||||
for _, exp := range expenses {
|
||||
@ExpenseListItem(spaceID, exp, methods, tags)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
<div class="border-t p-2">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage-1),
|
||||
"hx-target": "#expenses-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, pg),
|
||||
"hx-target": "#expenses-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage+1),
|
||||
"hx-target": "#expenses-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium">{ exp.Description }</p>
|
||||
if exp.RecurringExpenseID != nil {
|
||||
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ exp.Date.Format("Jan 02, 2006") }
|
||||
if exp.PaymentMethod != nil {
|
||||
if exp.PaymentMethod.LastFour != nil {
|
||||
<span>· { exp.PaymentMethod.Name } (*{ *exp.PaymentMethod.LastFour })</span>
|
||||
} else {
|
||||
<span>· { exp.PaymentMethod.Name }</span>
|
||||
}
|
||||
} else {
|
||||
<span>· Cash</span>
|
||||
}
|
||||
</p>
|
||||
if len(exp.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
for _, t := range exp.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
if exp.Type == model.ExpenseTypeExpense {
|
||||
<p class="font-bold text-destructive">
|
||||
- { model.FormatMoney(exp.Amount) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="font-bold text-green-500">
|
||||
+ { model.FormatMoney(exp.Amount) }
|
||||
</p>
|
||||
}
|
||||
// Edit button
|
||||
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the details of this transaction.
|
||||
}
|
||||
}
|
||||
@expense.EditExpenseForm(spaceID, exp, methods, tags)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID),
|
||||
"hx-target": "#expense-" + exp.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, currentPage, totalPages int) {
|
||||
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
|
||||
@expense.BalanceCard(spaceID, balance, allocated, true)
|
||||
}
|
||||
|
||||
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
@ExpenseListItem(spaceID, exp, methods, tags)
|
||||
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []*model.ListItem) {
|
||||
@layouts.Space(list.Name, space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@shoppinglist.ListNameHeader(space.ID, list)
|
||||
@dialog.Dialog(dialog.Props{ID: "delete-list-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost}) {
|
||||
Delete List
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Shopping List
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items. This action cannot be undone.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": "/app/spaces/" + space.ID + "/lists/" + list.ID,
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
|
||||
hx-target="#items-container"
|
||||
hx-swap="beforeend"
|
||||
_="on htmx:afterRequest reset() me"
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Placeholder: "New item...",
|
||||
Attributes: templ.Attributes{
|
||||
"autocomplete": "off",
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
Add Item
|
||||
}
|
||||
</form>
|
||||
<div
|
||||
id="items-container"
|
||||
class="border rounded-lg"
|
||||
>
|
||||
@ShoppingListItems(space.ID, items)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ShoppingListItems(spaceID string, items []*model.ListItem) {
|
||||
if len(items) == 0 {
|
||||
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
|
||||
} else {
|
||||
for _, item := range items {
|
||||
@shoppinglist.ItemDetail(spaceID, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
|
||||
@layouts.Space("Shopping Lists", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Shopping Lists</h1>
|
||||
</div>
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + space.ID + "/lists" }
|
||||
hx-target="#lists-container"
|
||||
hx-swap="beforeend"
|
||||
_="on htmx:afterRequest reset() me"
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Placeholder: "New list name...",
|
||||
})
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</form>
|
||||
<div
|
||||
id="lists-container"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
@ListsContainer(space.ID, cards)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ListsContainer(spaceID string, cards []model.ListCardData) {
|
||||
for _, card := range cards {
|
||||
@shoppinglist.ListCard(spaceID, card.List, card.Items, card.CurrentPage, card.TotalPages)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,608 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
|
||||
@layouts.Space(loan.Name, space) {
|
||||
<div class="space-y-6">
|
||||
// Loan Summary Card
|
||||
@LoanSummaryCard(space.ID, loan)
|
||||
|
||||
// Actions
|
||||
if !loan.IsPaidOff {
|
||||
<div class="flex gap-2">
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeSm}) {
|
||||
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
|
||||
Make Payment
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Make Payment
|
||||
}
|
||||
@dialog.Description() {
|
||||
Record a payment toward { loan.Name }
|
||||
}
|
||||
}
|
||||
@CreateReceiptForm(space.ID, loan.ID, accounts, availableBalance)
|
||||
}
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeSm, Variant: button.VariantOutline}) {
|
||||
@icon.Repeat(icon.Props{Class: "size-4 mr-1"})
|
||||
Set Up Recurring
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Recurring Payment
|
||||
}
|
||||
@dialog.Description() {
|
||||
Automatically create payments on a schedule
|
||||
}
|
||||
}
|
||||
@CreateRecurringReceiptForm(space.ID, loan.ID, accounts, availableBalance)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Recurring Receipts
|
||||
if len(recurringReceipts) > 0 {
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold">Recurring Payments</h2>
|
||||
<div class="border rounded-lg divide-y">
|
||||
for _, rr := range recurringReceipts {
|
||||
@RecurringReceiptItem(space.ID, loan.ID, rr)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Receipt History
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-lg font-semibold">Payment History</h2>
|
||||
<div class="border rounded-lg">
|
||||
<div id="receipts-list-wrapper">
|
||||
@ReceiptsListContent(space.ID, loan.ID, receipts, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
|
||||
{{ progressPct := 0 }}
|
||||
if !loan.OriginalAmount.IsZero() {
|
||||
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
|
||||
if progressPct > 100 {
|
||||
{{ progressPct = 100 }}
|
||||
}
|
||||
}
|
||||
@card.Card(card.Props{}) {
|
||||
@card.Header() {
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
@card.Title() {
|
||||
{ loan.Name }
|
||||
}
|
||||
@card.Description() {
|
||||
if loan.Description != "" {
|
||||
{ loan.Description }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
if loan.IsPaidOff {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
|
||||
Paid Off
|
||||
}
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
|
||||
@icon.Trash2(icon.Props{Class: "size-4"})
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Loan
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure? This will delete all payment records for this loan. Linked expenses and account transfers will be kept as history.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID),
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@card.Content() {
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Original</p>
|
||||
<p class="text-lg font-semibold">{ model.FormatMoney(loan.OriginalAmount) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Paid</p>
|
||||
<p class="text-lg font-semibold text-green-600">{ model.FormatMoney(loan.TotalPaid) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Remaining</p>
|
||||
<p class="text-lg font-semibold">
|
||||
if loan.Remaining.GreaterThan(decimal.Zero) {
|
||||
{ model.FormatMoney(loan.Remaining) }
|
||||
} else {
|
||||
$0.00
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@progress.Progress(progress.Props{
|
||||
Value: progressPct,
|
||||
Max: 100,
|
||||
Class: "h-3",
|
||||
})
|
||||
<div class="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{ strconv.Itoa(progressPct) }% paid</span>
|
||||
if loan.InterestRateBps > 0 {
|
||||
<span>{ fmt.Sprintf("%.2f%% interest", float64(loan.InterestRateBps)/100.0) }</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ ReceiptsListContent(spaceID, loanID string, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int) {
|
||||
<div id="receipts-list" class="divide-y">
|
||||
if len(receipts) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No payments recorded yet.</p>
|
||||
}
|
||||
for _, receipt := range receipts {
|
||||
@ReceiptListItem(spaceID, loanID, receipt)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
<div class="border-t p-2">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage-1),
|
||||
"hx-target": "#receipts-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, pg),
|
||||
"hx-target": "#receipts-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage+1),
|
||||
"hx-target": "#receipts-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesAndAccounts) {
|
||||
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{ model.FormatMoney(receipt.TotalAmount) }</span>
|
||||
<span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span>
|
||||
if receipt.RecurringReceiptID != nil {
|
||||
@icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"})
|
||||
}
|
||||
</div>
|
||||
if receipt.Description != "" {
|
||||
<p class="text-sm text-muted-foreground">{ receipt.Description }</p>
|
||||
}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
for _, src := range receipt.Sources {
|
||||
if src.SourceType == "balance" {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
|
||||
{ fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
|
||||
}
|
||||
} else {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
|
||||
{ fmt.Sprintf("%s %s", src.AccountName, model.FormatMoney(src.Amount)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
|
||||
@icon.Trash2(icon.Props{Class: "size-4"})
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Payment
|
||||
}
|
||||
@dialog.Description() {
|
||||
This will also reverse the linked expense and account transfers.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/receipts/%s", spaceID, loanID, receipt.ID),
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWithSources) {
|
||||
<div class="p-4 flex justify-between items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@icon.Repeat(icon.Props{Class: "size-4"})
|
||||
<span class="font-medium">{ model.FormatMoney(rr.TotalAmount) }</span>
|
||||
<span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span>
|
||||
if !rr.IsActive {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
Paused
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if rr.Description != "" {
|
||||
<p class="text-sm text-muted-foreground">{ rr.Description }</p>
|
||||
}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Next: { rr.NextOccurrence.Format("Jan 2, 2006") }
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
for _, src := range rr.Sources {
|
||||
if src.SourceType == "balance" {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
|
||||
{ fmt.Sprintf("Balance %s", model.FormatMoney(src.Amount)) }
|
||||
}
|
||||
} else {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
|
||||
if src.AccountID != nil {
|
||||
{ fmt.Sprintf("Account %s", model.FormatMoney(src.Amount)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@button.Button(button.Props{
|
||||
Size: button.SizeIcon,
|
||||
Variant: button.VariantGhost,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-post": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s/toggle", spaceID, loanID, rr.ID),
|
||||
},
|
||||
}) {
|
||||
if rr.IsActive {
|
||||
@icon.Pause(icon.Props{Class: "size-4"})
|
||||
} else {
|
||||
@icon.Play(icon.Props{Class: "size-4"})
|
||||
}
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
|
||||
@icon.Trash2(icon.Props{Class: "size-4"})
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Recurring Payment
|
||||
}
|
||||
@dialog.Description() {
|
||||
This will stop future automatic payments. Past payments are not affected.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s", spaceID, loanID, rr.ID),
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "amount",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0.01", "required": "true",
|
||||
},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Date
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeDate,
|
||||
Name: "date",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Description (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeText,
|
||||
Name: "description",
|
||||
Placeholder: "Payment note",
|
||||
})
|
||||
}
|
||||
// Funding Sources
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Funding Sources</label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Available balance: { model.FormatMoney(availableBalance) }
|
||||
</p>
|
||||
<div id="funding-sources" class="space-y-2">
|
||||
<div class="flex gap-2 items-center source-row">
|
||||
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
|
||||
<option value="balance">General Balance</option>
|
||||
for _, acct := range accounts {
|
||||
<option value="account" data-account-id={ acct.ID }>
|
||||
{ acct.Name } ({ model.FormatMoney(acct.Balance) })
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<input type="hidden" name="source_account_id" value=""/>
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "source_amount",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0.01", "required": "true",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary hover:underline"
|
||||
_="on click
|
||||
set row to the first .source-row
|
||||
set clone to row.cloneNode(true)
|
||||
put '' into the value of the first <select/> in clone
|
||||
put '' into the value of the first <input[type='hidden']/> in clone
|
||||
put '' into the value of the first <input[type='number']/> in clone
|
||||
append clone to #funding-sources"
|
||||
>
|
||||
+ Add Source
|
||||
</button>
|
||||
</div>
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{Type: "submit"}) {
|
||||
Record Payment
|
||||
}
|
||||
}
|
||||
</form>
|
||||
<script>
|
||||
// Update hidden account_id when select changes
|
||||
document.getElementById('funding-sources').addEventListener('change', function(e) {
|
||||
if (e.target.tagName === 'SELECT') {
|
||||
const selected = e.target.options[e.target.selectedIndex];
|
||||
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
|
||||
if (selected.value === 'account') {
|
||||
hiddenInput.value = selected.dataset.accountId || '';
|
||||
} else {
|
||||
hiddenInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance decimal.Decimal) {
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "amount",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0.01", "required": "true",
|
||||
},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Frequency
|
||||
}
|
||||
<select name="frequency" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" required>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Start Date
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeDate,
|
||||
Name: "start_date",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
End Date (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeDate,
|
||||
Name: "end_date",
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Description (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeText,
|
||||
Name: "description",
|
||||
Placeholder: "Payment note",
|
||||
})
|
||||
}
|
||||
// Funding Sources
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Funding Sources</label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Current balance: { model.FormatMoney(availableBalance) }
|
||||
</p>
|
||||
<div id="recurring-funding-sources" class="space-y-2">
|
||||
<div class="flex gap-2 items-center recurring-source-row">
|
||||
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
|
||||
<option value="balance">General Balance</option>
|
||||
for _, acct := range accounts {
|
||||
<option value="account" data-account-id={ acct.ID }>
|
||||
{ acct.Name } ({ model.FormatMoney(acct.Balance) })
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<input type="hidden" name="source_account_id" value=""/>
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "source_amount",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0.01", "required": "true",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary hover:underline"
|
||||
_="on click
|
||||
set row to the first .recurring-source-row
|
||||
set clone to row.cloneNode(true)
|
||||
put '' into the value of the first <select/> in clone
|
||||
put '' into the value of the first <input[type='hidden']/> in clone
|
||||
put '' into the value of the first <input[type='number']/> in clone
|
||||
append clone to #recurring-funding-sources"
|
||||
>
|
||||
+ Add Source
|
||||
</button>
|
||||
</div>
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{Type: "submit"}) {
|
||||
Create Recurring Payment
|
||||
}
|
||||
}
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('recurring-funding-sources').addEventListener('change', function(e) {
|
||||
if (e.target.tagName === 'SELECT') {
|
||||
const selected = e.target.options[e.target.selectedIndex];
|
||||
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
|
||||
if (selected.value === 'account') {
|
||||
hiddenInput.value = selected.dataset.accountId || '';
|
||||
} else {
|
||||
hiddenInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceLoansPage(space *model.Space, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
|
||||
@layouts.Space("Loans", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Loans</h1>
|
||||
@dialog.Dialog(dialog.Props{}) {
|
||||
@dialog.Trigger(dialog.TriggerProps{}) {
|
||||
@button.Button(button.Props{Size: button.SizeSm}) {
|
||||
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
|
||||
New Loan
|
||||
}
|
||||
}
|
||||
@dialog.Content(dialog.ContentProps{}) {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
New Loan
|
||||
}
|
||||
@dialog.Description() {
|
||||
Track a new loan or financing
|
||||
}
|
||||
}
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/loans", space.ID) }
|
||||
hx-target="#loans-list-wrapper"
|
||||
hx-swap="innerHTML"
|
||||
_="on htmx:afterRequest if event.detail.successful call window.tui.dialog.close() then reset() me"
|
||||
>
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeText,
|
||||
Name: "name",
|
||||
Placeholder: "e.g., Car Loan",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Total Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "amount",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0.01",
|
||||
"required": "true",
|
||||
},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Interest Rate (%)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeNumber,
|
||||
Name: "interest_rate",
|
||||
Placeholder: "0.00",
|
||||
Attributes: templ.Attributes{
|
||||
"step": "0.01",
|
||||
"min": "0",
|
||||
},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Start Date
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeDate,
|
||||
Name: "start_date",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
End Date (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeDate,
|
||||
Name: "end_date",
|
||||
})
|
||||
}
|
||||
@form.Item() {
|
||||
@form.Label() {
|
||||
Description (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Type: input.TypeText,
|
||||
Name: "description",
|
||||
Placeholder: "Additional notes about this loan",
|
||||
})
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@button.Button(button.Props{Type: "submit"}) {
|
||||
Create Loan
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div id="loans-list-wrapper">
|
||||
@LoansListContent(space.ID, loans, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoansListContent(spaceID string, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
if len(loans) == 0 {
|
||||
<p class="text-sm text-muted-foreground col-span-full">No loans yet. Create one to start tracking payments.</p>
|
||||
}
|
||||
for _, loan := range loans {
|
||||
@LoanCard(spaceID, loan)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
<div class="mt-4">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage-1),
|
||||
"hx-target": "#loans-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, pg),
|
||||
"hx-target": "#loans-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/loans?page=%d", spaceID, currentPage+1),
|
||||
"hx-target": "#loans-list-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
|
||||
{{ progressPct := 0 }}
|
||||
if !loan.OriginalAmount.IsZero() {
|
||||
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
|
||||
if progressPct > 100 {
|
||||
{{ progressPct = 100 }}
|
||||
}
|
||||
}
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID)) } class="block">
|
||||
@card.Card(card.Props{Class: "hover:border-primary/50 transition-colors cursor-pointer"}) {
|
||||
@card.Header() {
|
||||
<div class="flex justify-between items-start">
|
||||
@card.Title() {
|
||||
{ loan.Name }
|
||||
}
|
||||
if loan.IsPaidOff {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
|
||||
Paid Off
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@card.Description() {
|
||||
{ model.FormatMoney(loan.OriginalAmount) }
|
||||
if loan.InterestRateBps > 0 {
|
||||
{ fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
@progress.Progress(progress.Props{
|
||||
Value: progressPct,
|
||||
Max: 100,
|
||||
Class: "h-2",
|
||||
})
|
||||
<div class="flex justify-between text-sm text-muted-foreground mt-2">
|
||||
<span>Paid: { model.FormatMoney(loan.TotalPaid) }</span>
|
||||
if loan.Remaining.GreaterThan(decimal.Zero) {
|
||||
<span>Left: { model.FormatMoney(loan.Remaining) }</span>
|
||||
} else {
|
||||
<span class="text-green-600">Fully paid</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{ strconv.Itoa(loan.ReceiptCount) } payment(s)
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"github.com/shopspring/decimal"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
|
||||
)
|
||||
|
||||
type OverviewData struct {
|
||||
Space *model.Space
|
||||
Balance decimal.Decimal
|
||||
Allocated decimal.Decimal
|
||||
Report *model.SpendingReport
|
||||
Budgets []*model.BudgetWithSpent
|
||||
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
|
||||
ShoppingLists []model.ListCardData
|
||||
Tags []*model.Tag
|
||||
Methods []*model.PaymentMethod
|
||||
ListsWithItems []model.ListWithUncheckedItems
|
||||
}
|
||||
|
||||
func overviewProgressBarColor(status model.BudgetStatus) string {
|
||||
switch status {
|
||||
case model.BudgetStatusOver:
|
||||
return "bg-destructive"
|
||||
case model.BudgetStatusWarning:
|
||||
return "bg-yellow-500"
|
||||
default:
|
||||
return "bg-green-500"
|
||||
}
|
||||
}
|
||||
|
||||
func overviewPeriodLabel(p model.BudgetPeriod) string {
|
||||
switch p {
|
||||
case model.BudgetPeriodWeekly:
|
||||
return "Weekly"
|
||||
case model.BudgetPeriodYearly:
|
||||
return "Yearly"
|
||||
default:
|
||||
return "Monthly"
|
||||
}
|
||||
}
|
||||
|
||||
func overviewFrequencyLabel(f model.Frequency) string {
|
||||
switch f {
|
||||
case model.FrequencyDaily:
|
||||
return "Daily"
|
||||
case model.FrequencyWeekly:
|
||||
return "Weekly"
|
||||
case model.FrequencyBiweekly:
|
||||
return "Biweekly"
|
||||
case model.FrequencyMonthly:
|
||||
return "Monthly"
|
||||
case model.FrequencyYearly:
|
||||
return "Yearly"
|
||||
default:
|
||||
return string(f)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedActiveRecurring(recs []*model.RecurringExpenseWithTagsAndMethod) []*model.RecurringExpenseWithTagsAndMethod {
|
||||
var active []*model.RecurringExpenseWithTagsAndMethod
|
||||
for _, r := range recs {
|
||||
if r.IsActive {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(active, func(i, j int) bool {
|
||||
return active[i].NextOccurrence.Before(active[j].NextOccurrence)
|
||||
})
|
||||
return active
|
||||
}
|
||||
|
||||
func uncheckedCount(items []*model.ListItem) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if !item.IsChecked {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
var overviewChartColors = []string{
|
||||
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
|
||||
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
|
||||
}
|
||||
|
||||
func overviewChartColor(i int, tagColor *string) string {
|
||||
if tagColor != nil && *tagColor != "" {
|
||||
return *tagColor
|
||||
}
|
||||
return overviewChartColors[i%len(overviewChartColors)]
|
||||
}
|
||||
|
||||
templ SpaceOverviewPage(data OverviewData) {
|
||||
@layouts.Space("Overview", data.Space) {
|
||||
@chart.Script()
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Overview</h1>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
// Row 1: Balance + This Month summary
|
||||
@overviewBalanceCard(data)
|
||||
@overviewSpendingCard(data)
|
||||
// Row 2: Charts
|
||||
@overviewSpendingByCategoryChart(data)
|
||||
@overviewSpendingOverTimeChart(data)
|
||||
// Row 3+: Detail cards
|
||||
@overviewBudgetsCard(data)
|
||||
@overviewRecurringCard(data)
|
||||
@overviewTopExpensesCard(data)
|
||||
@overviewShoppingListsCard(data)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ overviewSectionHeader(title, href string) {
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="font-semibold">{ title }</h3>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeSm,
|
||||
Attributes: templ.Attributes{
|
||||
"onclick": "window.location.href='" + href + "'",
|
||||
},
|
||||
}) {
|
||||
<span>View all</span>
|
||||
@icon.ChevronRight(icon.Props{Size: 16})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewBalanceCard(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold mb-3">Current Balance</h3>
|
||||
@dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods)
|
||||
</div>
|
||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance.IsNegative()) }>
|
||||
{ model.FormatMoney(data.Balance) }
|
||||
</p>
|
||||
if data.Allocated.GreaterThan(decimal.Zero) {
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{ model.FormatMoney(data.Allocated) } in accounts
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewSpendingCard(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("This Month", "/app/spaces/"+data.Space.ID+"/reports")
|
||||
if data.Report != nil {
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-green-500 font-medium">Income</span>
|
||||
<span class="text-sm font-bold text-green-500">{ model.FormatMoney(data.Report.TotalIncome) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-destructive font-medium">Expenses</span>
|
||||
<span class="text-sm font-bold text-destructive">{ model.FormatMoney(data.Report.TotalExpenses) }</span>
|
||||
</div>
|
||||
<hr class="border-border"/>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm font-medium">Net</span>
|
||||
<span class={ "text-sm font-bold", templ.KV("text-green-500", !data.Report.NetBalance.IsNegative()), templ.KV("text-destructive", data.Report.NetBalance.IsNegative()) }>
|
||||
{ model.FormatMoney(data.Report.NetBalance) }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">No data available.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewSpendingByCategoryChart(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Spending by Category</h3>
|
||||
if data.Report != nil && len(data.Report.ByTag) > 0 {
|
||||
{{
|
||||
tagLabels := make([]string, len(data.Report.ByTag))
|
||||
tagData := make([]float64, len(data.Report.ByTag))
|
||||
tagColors := make([]string, len(data.Report.ByTag))
|
||||
for i, t := range data.Report.ByTag {
|
||||
tagLabels[i] = t.TagName
|
||||
tagData[i] = t.TotalAmount.InexactFloat64()
|
||||
tagColors[i] = overviewChartColor(i, t.TagColor)
|
||||
}
|
||||
}}
|
||||
@chart.Chart(chart.Props{
|
||||
Variant: chart.VariantDoughnut,
|
||||
ShowLegend: true,
|
||||
Data: chart.Data{
|
||||
Labels: tagLabels,
|
||||
Datasets: []chart.Dataset{
|
||||
{
|
||||
Label: "Spending",
|
||||
Data: tagData,
|
||||
BackgroundColor: tagColors,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">No tagged expenses this month.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewSpendingOverTimeChart(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Spending Over Time</h3>
|
||||
if data.Report != nil && len(data.Report.DailySpending) > 0 {
|
||||
{{
|
||||
var timeLabels []string
|
||||
var timeData []float64
|
||||
for _, d := range data.Report.DailySpending {
|
||||
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
|
||||
timeData = append(timeData, d.Total.InexactFloat64())
|
||||
}
|
||||
}}
|
||||
@chart.Chart(chart.Props{
|
||||
Variant: chart.VariantBar,
|
||||
ShowYAxis: true,
|
||||
ShowXAxis: true,
|
||||
ShowXLabels: true,
|
||||
ShowYLabels: true,
|
||||
Data: chart.Data{
|
||||
Labels: timeLabels,
|
||||
Datasets: []chart.Dataset{
|
||||
{
|
||||
Label: "Spending",
|
||||
Data: timeData,
|
||||
BackgroundColor: "#3b82f6",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">No expenses this month.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewBudgetsCard(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("Budget Status", "/app/spaces/"+data.Space.ID+"/budgets")
|
||||
if len(data.Budgets) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No budgets set up yet.</p>
|
||||
} else {
|
||||
<div class="space-y-3">
|
||||
for i, b := range data.Budgets {
|
||||
if i < 3 {
|
||||
{{ pct := b.Percentage }}
|
||||
if pct > 100 {
|
||||
{{ pct = 100 }}
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
for _, t := range b.Tags {
|
||||
<span class="inline-flex items-center gap-1">
|
||||
if t.Color != nil {
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *t.Color }></span>
|
||||
}
|
||||
<span class="text-sm font-medium">{ t.Name }</span>
|
||||
</span>
|
||||
}
|
||||
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{ model.FormatMoney(b.Spent) } spent</span>
|
||||
<span>of { model.FormatMoney(b.Amount) }</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2">
|
||||
<div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewRecurringCard(data OverviewData) {
|
||||
{{ upcoming := sortedActiveRecurring(data.UpcomingRecurring) }}
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("Upcoming Recurring", "/app/spaces/"+data.Space.ID+"/recurring")
|
||||
if len(upcoming) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No active recurring payments.</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for i, r := range upcoming {
|
||||
if i < 5 {
|
||||
<div class="py-2 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{ r.Description }</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{ overviewFrequencyLabel(r.Frequency) } · Next: { r.NextOccurrence.Format("Jan 02") }
|
||||
</p>
|
||||
</div>
|
||||
if r.Type == model.ExpenseTypeExpense {
|
||||
<p class="text-sm font-bold text-destructive shrink-0">
|
||||
{ model.FormatMoney(r.Amount) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="text-sm font-bold text-green-500 shrink-0">
|
||||
+{ model.FormatMoney(r.Amount) }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewTopExpensesCard(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("Top Expenses", "/app/spaces/"+data.Space.ID+"/expenses")
|
||||
if data.Report == nil || len(data.Report.TopExpenses) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No expenses this month.</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for i, exp := range data.Report.TopExpenses {
|
||||
if i < 5 {
|
||||
<div class="py-2 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{ exp.Description }</p>
|
||||
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
|
||||
if len(exp.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-0.5">
|
||||
for _, t := range exp.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm font-bold text-destructive shrink-0">
|
||||
{ model.FormatMoney(exp.Amount) }
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewShoppingListsCard(data OverviewData) {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("Shopping Lists", "/app/spaces/"+data.Space.ID+"/lists")
|
||||
if len(data.ShoppingLists) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for i, card := range data.ShoppingLists {
|
||||
if i < 3 {
|
||||
<a href={ templ.SafeURL("/app/spaces/" + data.Space.ID + "/lists/" + card.List.ID) } class="py-2 flex justify-between items-center hover:bg-accent/50 -mx-1 px-1 rounded transition-colors">
|
||||
<span class="text-sm font-medium">{ card.List.Name }</span>
|
||||
{{ uc := uncheckedCount(card.Items) }}
|
||||
if uc > 0 {
|
||||
<span class="text-xs text-muted-foreground">{ fmt.Sprintf("%d unchecked", uc) }</span>
|
||||
} else {
|
||||
<span class="text-xs text-muted-foreground">All done</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpacePaymentMethodsPage(space *model.Space, methods []*model.PaymentMethod) {
|
||||
@layouts.Space("Payment Methods", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Payment Methods</h1>
|
||||
@dialog.Dialog(dialog.Props{ID: "add-method-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button() {
|
||||
Add Method
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Add Payment Method
|
||||
}
|
||||
@dialog.Description() {
|
||||
Add a credit or debit card to track how you pay for expenses.
|
||||
}
|
||||
}
|
||||
@paymentmethod.CreateMethodForm(space.ID, "add-method-dialog")
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div id="methods-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
if len(methods) == 0 {
|
||||
<p class="text-sm text-muted-foreground col-span-full">No payment methods yet. Add one to start tracking how you pay for expenses.</p>
|
||||
}
|
||||
for _, method := range methods {
|
||||
@paymentmethod.MethodItem(space.ID, method)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithTagsAndMethod, tags []*model.Tag, methods []*model.PaymentMethod) {
|
||||
@layouts.Space("Recurring", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Recurring Transactions</h1>
|
||||
@dialog.Dialog(dialog.Props{ID: "add-recurring-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button() {
|
||||
Add Recurring
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Add Recurring Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Set up a recurring expense or top-up that will auto-generate on schedule.
|
||||
}
|
||||
}
|
||||
@recurring.AddRecurringForm(space.ID, tags, methods, "add-recurring-dialog")
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="border rounded-lg">
|
||||
<div id="recurring-list" class="divide-y">
|
||||
if len(recs) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
|
||||
}
|
||||
for _, re := range recs {
|
||||
@recurring.RecurringItem(space.ID, re, methods, tags)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/chart"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
var defaultChartColors = []string{
|
||||
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
|
||||
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
|
||||
}
|
||||
|
||||
func chartColor(i int, tagColor *string) string {
|
||||
if tagColor != nil && *tagColor != "" {
|
||||
return *tagColor
|
||||
}
|
||||
return defaultChartColors[i%len(defaultChartColors)]
|
||||
}
|
||||
|
||||
templ SpaceReportsPage(space *model.Space, report *model.SpendingReport, presets []service.DateRange, activeRange string) {
|
||||
@layouts.Space("Reports", space) {
|
||||
@chart.Script()
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Reports</h1>
|
||||
<div id="report-content">
|
||||
@ReportCharts(space.ID, report, presets[0].From, presets[0].To, presets, activeRange)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.Time, presets []service.DateRange, activeRange string) {
|
||||
// Date range selector
|
||||
<div class="flex flex-wrap gap-2 items-center mb-4">
|
||||
for _, p := range presets {
|
||||
if p.Key == activeRange {
|
||||
@button.Button(button.Props{
|
||||
Size: button.SizeSm,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
|
||||
"hx-target": "#report-content",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ p.Label }
|
||||
}
|
||||
} else {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Size: button.SizeSm,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
|
||||
"hx-target": "#report-content",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ p.Label }
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2 overflow-hidden">
|
||||
// Income vs Expenses Summary
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground space-y-2 min-w-0">
|
||||
<h3 class="font-semibold">Income vs Expenses</h3>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-green-500 font-medium">Income</span>
|
||||
<span class="font-bold text-green-500">{ model.FormatMoney(report.TotalIncome) }</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-destructive font-medium">Expenses</span>
|
||||
<span class="font-bold text-destructive">{ model.FormatMoney(report.TotalExpenses) }</span>
|
||||
</div>
|
||||
<hr class="border-border"/>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Net</span>
|
||||
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance.GreaterThanOrEqual(decimal.Zero)), templ.KV("text-destructive", report.NetBalance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(report.NetBalance) }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Spending by Tag (Doughnut chart)
|
||||
if len(report.ByTag) > 0 {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Spending by Category</h3>
|
||||
{{
|
||||
tagLabels := make([]string, len(report.ByTag))
|
||||
tagData := make([]float64, len(report.ByTag))
|
||||
tagColors := make([]string, len(report.ByTag))
|
||||
for i, t := range report.ByTag {
|
||||
tagLabels[i] = t.TagName
|
||||
tagData[i] = t.TotalAmount.InexactFloat64()
|
||||
tagColors[i] = chartColor(i, t.TagColor)
|
||||
}
|
||||
}}
|
||||
@chart.Chart(chart.Props{
|
||||
Variant: chart.VariantDoughnut,
|
||||
ShowLegend: true,
|
||||
Data: chart.Data{
|
||||
Labels: tagLabels,
|
||||
Datasets: []chart.Dataset{
|
||||
{
|
||||
Label: "Spending",
|
||||
Data: tagData,
|
||||
BackgroundColor: tagColors,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
</div>
|
||||
} else {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
|
||||
<h3 class="font-semibold mb-2">Spending by Category</h3>
|
||||
<p class="text-sm text-muted-foreground">No tagged expenses in this period.</p>
|
||||
</div>
|
||||
}
|
||||
// Spending by Payment Method (Doughnut chart)
|
||||
if len(report.ByPaymentMethod) > 0 {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
|
||||
{{
|
||||
pmLabels := make([]string, len(report.ByPaymentMethod))
|
||||
pmData := make([]float64, len(report.ByPaymentMethod))
|
||||
pmColors := make([]string, len(report.ByPaymentMethod))
|
||||
for i, pm := range report.ByPaymentMethod {
|
||||
pmLabels[i] = pm.PaymentMethodName
|
||||
pmData[i] = pm.TotalAmount.InexactFloat64()
|
||||
pmColors[i] = defaultChartColors[i%len(defaultChartColors)]
|
||||
}
|
||||
}}
|
||||
@chart.Chart(chart.Props{
|
||||
Variant: chart.VariantDoughnut,
|
||||
ShowLegend: true,
|
||||
Data: chart.Data{
|
||||
Labels: pmLabels,
|
||||
Datasets: []chart.Dataset{
|
||||
{
|
||||
Label: "Spending",
|
||||
Data: pmData,
|
||||
BackgroundColor: pmColors,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
</div>
|
||||
} else {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
|
||||
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
|
||||
<p class="text-sm text-muted-foreground">No payment method data in this period.</p>
|
||||
</div>
|
||||
}
|
||||
// Spending Over Time (Bar chart)
|
||||
if len(report.DailySpending) > 0 || len(report.MonthlySpending) > 0 {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Spending Over Time</h3>
|
||||
{{
|
||||
days := to.Sub(from).Hours() / 24
|
||||
var timeLabels []string
|
||||
var timeData []float64
|
||||
if days <= 31 {
|
||||
for _, d := range report.DailySpending {
|
||||
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
|
||||
timeData = append(timeData, d.Total.InexactFloat64())
|
||||
}
|
||||
} else {
|
||||
for _, m := range report.MonthlySpending {
|
||||
timeLabels = append(timeLabels, m.Month)
|
||||
timeData = append(timeData, m.Total.InexactFloat64())
|
||||
}
|
||||
}
|
||||
}}
|
||||
@chart.Chart(chart.Props{
|
||||
Variant: chart.VariantBar,
|
||||
ShowYAxis: true,
|
||||
ShowXAxis: true,
|
||||
ShowXLabels: true,
|
||||
ShowYLabels: true,
|
||||
Data: chart.Data{
|
||||
Labels: timeLabels,
|
||||
Datasets: []chart.Dataset{
|
||||
{
|
||||
Label: "Spending",
|
||||
Data: timeData,
|
||||
BackgroundColor: "#3b82f6",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
</div>
|
||||
} else {
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
|
||||
<h3 class="font-semibold mb-2">Spending Over Time</h3>
|
||||
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
|
||||
</div>
|
||||
}
|
||||
// Top 10 Expenses
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
|
||||
<h3 class="font-semibold mb-2">Top Expenses</h3>
|
||||
if len(report.TopExpenses) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for _, exp := range report.TopExpenses {
|
||||
<div class="py-2 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-sm truncate">{ exp.Description }</p>
|
||||
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
|
||||
if len(exp.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-0.5">
|
||||
for _, t := range exp.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="font-bold text-destructive text-sm shrink-0">
|
||||
{ model.FormatMoney(exp.Amount) }
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,402 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/timezone"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) {
|
||||
@layouts.Space("Settings", space) {
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
// Space Name Section
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
Space Name
|
||||
}
|
||||
@card.Description() {
|
||||
if isOwner {
|
||||
Update the name of this space.
|
||||
} else {
|
||||
The name of this space.
|
||||
}
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
if isOwner {
|
||||
<form
|
||||
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
|
||||
hx-swap="none"
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Value: space.Name,
|
||||
Attributes: templ.Attributes{
|
||||
"autocomplete": "off",
|
||||
"required": true,
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</form>
|
||||
} else {
|
||||
<p class="text-sm">{ space.Name }</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
// Timezone Section
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
Timezone
|
||||
}
|
||||
@card.Description() {
|
||||
if isOwner {
|
||||
Set a timezone for this space. Recurring expenses and reports will use this timezone.
|
||||
} else {
|
||||
The timezone used for recurring expenses and reports in this space.
|
||||
}
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
if isOwner {
|
||||
<form
|
||||
hx-patch={ "/app/spaces/" + space.ID + "/settings/timezone" }
|
||||
hx-swap="none"
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
@form.Item() {
|
||||
@label.Label(label.Props{
|
||||
For: "timezone",
|
||||
Class: "block mb-2",
|
||||
}) {
|
||||
Timezone
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "space-timezone-select"}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
|
||||
for _, tz := range timezone.CommonTimezones() {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: space.Timezone != nil && tz.Value == *space.Timezone}) {
|
||||
{ tz.Label }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@button.Submit() {
|
||||
Save Timezone
|
||||
}
|
||||
</form>
|
||||
} else {
|
||||
if space.Timezone != nil && *space.Timezone != "" {
|
||||
<p class="text-sm">{ *space.Timezone }</p>
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">Not set (uses your timezone)</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Members Section
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
<div class="flex items-center gap-2">
|
||||
@icon.Users(icon.Props{Class: "size-5"})
|
||||
Members
|
||||
</div>
|
||||
}
|
||||
@card.Description() {
|
||||
People who have access to this space.
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<div class="divide-y" id="members-list">
|
||||
for _, member := range members {
|
||||
@MemberRow(space.ID, member, isOwner, currentUserID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
// Invitations Section (owner only)
|
||||
if isOwner {
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
<div class="flex items-center gap-2">
|
||||
@icon.Mail(icon.Props{Class: "size-5"})
|
||||
Invitations
|
||||
</div>
|
||||
}
|
||||
@card.Description() {
|
||||
Invite new members and manage pending invitations.
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<div class="space-y-4">
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + space.ID + "/invites" }
|
||||
hx-swap="none"
|
||||
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "email",
|
||||
Placeholder: "Email address...",
|
||||
Attributes: templ.Attributes{
|
||||
"type": "email",
|
||||
"autocomplete": "off",
|
||||
"required": true,
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
@icon.UserPlus(icon.Props{Class: "size-4"})
|
||||
Invite
|
||||
}
|
||||
</form>
|
||||
<div
|
||||
id="pending-invites"
|
||||
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
|
||||
hx-trigger="refreshInvites from:body"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
if len(pendingInvites) > 0 {
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
|
||||
<div class="divide-y">
|
||||
for _, invite := range pendingInvites {
|
||||
@PendingInviteRow(space.ID, invite)
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">No pending invitations.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
// Danger Zone (owner only)
|
||||
if isOwner {
|
||||
@card.Card() {
|
||||
@card.Header() {
|
||||
@card.Title() {
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
@icon.TriangleAlert(icon.Props{Class: "size-5"})
|
||||
Danger Zone
|
||||
</div>
|
||||
}
|
||||
@card.Description() {
|
||||
Irreversible and destructive actions.
|
||||
}
|
||||
}
|
||||
@card.Content() {
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Delete this space</p>
|
||||
<p class="text-sm text-muted-foreground">Once deleted, all data in this space will be permanently removed.</p>
|
||||
</div>
|
||||
{{ deleteDialogID := "delete-space-dialog-" + space.ID }}
|
||||
@dialog.Dialog(dialog.Props{ID: deleteDialogID, DisableClickAway: true}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Type: button.TypeButton,
|
||||
}) {
|
||||
Delete Space
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete space
|
||||
}
|
||||
@dialog.Description() {
|
||||
This action is permanent and cannot be undone. All data including expenses, budgets, shopping lists, and members will be permanently deleted.
|
||||
}
|
||||
}
|
||||
<form
|
||||
hx-delete={ "/app/spaces/" + space.ID }
|
||||
hx-swap="none"
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div class="space-y-2">
|
||||
@label.Label(label.Props{For: "confirmation_name"}) {
|
||||
Type <span class="font-semibold">{ space.Name }</span> to confirm
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "confirmation_name",
|
||||
Placeholder: space.Name,
|
||||
Attributes: templ.Attributes{
|
||||
"id": "confirmation_name",
|
||||
"autocomplete": "off",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Type: button.TypeButton,
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Type: button.TypeSubmit,
|
||||
Attributes: templ.Attributes{
|
||||
"id": "delete-space-confirm",
|
||||
},
|
||||
}) {
|
||||
Delete Space
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@dialog.Script()
|
||||
}
|
||||
}
|
||||
|
||||
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
|
||||
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{ string([]rune(member.Name)[0]) }
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{ member.Name }</p>
|
||||
<p class="text-xs text-muted-foreground">{ member.Email }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
if member.Role == model.RoleOwner {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
|
||||
@icon.Crown(icon.Props{Class: "size-3"})
|
||||
Owner
|
||||
}
|
||||
} else {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
Member
|
||||
}
|
||||
}
|
||||
if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner {
|
||||
{{ dialogID := "remove-member-dialog-" + member.UserID }}
|
||||
@dialog.Dialog(dialog.Props{ID: dialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Type: button.TypeButton,
|
||||
}) {
|
||||
@icon.UserMinus(icon.Props{Class: "size-4 text-destructive"})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Remove member
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to remove { member.Name } from this space? They will lose access immediately.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Type: button.TypeButton,
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Type: button.TypeButton,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID,
|
||||
"hx-target": "#member-" + member.UserID,
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
|
||||
},
|
||||
}) {
|
||||
Remove
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
|
||||
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
|
||||
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{ invite.Email }</p>
|
||||
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
|
||||
Pending
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Type: button.TypeButton,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token,
|
||||
"hx-target": "#invite-" + invite.Token,
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
|
||||
},
|
||||
}) {
|
||||
@icon.X(icon.Props{Class: "size-4 text-destructive"})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
|
||||
if len(pendingInvites) > 0 {
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
|
||||
<div class="divide-y">
|
||||
for _, invite := range pendingInvites {
|
||||
@PendingInviteRow(spaceID, invite)
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<p class="text-sm text-muted-foreground">No pending invitations.</p>
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
|
||||
@layouts.Space("Tags", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Tags</h1>
|
||||
</div>
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + space.ID + "/tags" }
|
||||
hx-target="#tags-container"
|
||||
hx-swap="beforeend"
|
||||
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me"
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Placeholder: "New tag name...",
|
||||
Attributes: templ.Attributes{
|
||||
"autocomplete": "off",
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</form>
|
||||
<div
|
||||
id="tags-container"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
for _, t := range tags {
|
||||
@tag.Tag(t)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue