feat: payment methods
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m1s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m1s
This commit is contained in:
parent
364f8dc682
commit
3de76916c9
15 changed files with 946 additions and 100 deletions
|
|
@ -11,18 +11,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
UserService *service.UserService
|
UserService *service.UserService
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
EmailService *service.EmailService
|
EmailService *service.EmailService
|
||||||
ProfileService *service.ProfileService
|
ProfileService *service.ProfileService
|
||||||
SpaceService *service.SpaceService
|
SpaceService *service.SpaceService
|
||||||
TagService *service.TagService
|
TagService *service.TagService
|
||||||
ShoppingListService *service.ShoppingListService
|
ShoppingListService *service.ShoppingListService
|
||||||
ExpenseService *service.ExpenseService
|
ExpenseService *service.ExpenseService
|
||||||
InviteService *service.InviteService
|
InviteService *service.InviteService
|
||||||
MoneyAccountService *service.MoneyAccountService
|
MoneyAccountService *service.MoneyAccountService
|
||||||
|
PaymentMethodService *service.PaymentMethodService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -49,6 +50,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
expenseRepository := repository.NewExpenseRepository(database)
|
expenseRepository := repository.NewExpenseRepository(database)
|
||||||
invitationRepository := repository.NewInvitationRepository(database)
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
moneyAccountRepository := repository.NewMoneyAccountRepository(database)
|
moneyAccountRepository := repository.NewMoneyAccountRepository(database)
|
||||||
|
paymentMethodRepository := repository.NewPaymentMethodRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
|
@ -77,20 +79,22 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
expenseService := service.NewExpenseService(expenseRepository)
|
expenseService := service.NewExpenseService(expenseRepository)
|
||||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
||||||
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
|
moneyAccountService := service.NewMoneyAccountService(moneyAccountRepository)
|
||||||
|
paymentMethodService := service.NewPaymentMethodService(paymentMethodRepository)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
DB: database,
|
DB: database,
|
||||||
UserService: userService,
|
UserService: userService,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
EmailService: emailService,
|
EmailService: emailService,
|
||||||
ProfileService: profileService,
|
ProfileService: profileService,
|
||||||
SpaceService: spaceService,
|
SpaceService: spaceService,
|
||||||
TagService: tagService,
|
TagService: tagService,
|
||||||
ShoppingListService: shoppingListService,
|
ShoppingListService: shoppingListService,
|
||||||
ExpenseService: expenseService,
|
ExpenseService: expenseService,
|
||||||
InviteService: inviteService,
|
InviteService: inviteService,
|
||||||
MoneyAccountService: moneyAccountService,
|
MoneyAccountService: moneyAccountService,
|
||||||
|
PaymentMethodService: paymentMethodService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE payment_methods (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('credit', 'debit')),
|
||||||
|
last_four TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(space_id, name),
|
||||||
|
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
ALTER TABLE expenses ADD COLUMN payment_method_id TEXT REFERENCES payment_methods(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX idx_payment_method_space_id ON payment_methods(space_id);
|
||||||
|
CREATE INDEX idx_expenses_payment_method_id ON expenses(payment_method_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX IF EXISTS idx_expenses_payment_method_id;
|
||||||
|
DROP INDEX IF EXISTS idx_payment_method_space_id;
|
||||||
|
ALTER TABLE expenses DROP COLUMN IF EXISTS payment_method_id;
|
||||||
|
DROP TABLE IF EXISTS payment_methods;
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
|
"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/components/toast"
|
||||||
|
|
@ -26,9 +27,10 @@ type SpaceHandler struct {
|
||||||
expenseService *service.ExpenseService
|
expenseService *service.ExpenseService
|
||||||
inviteService *service.InviteService
|
inviteService *service.InviteService
|
||||||
accountService *service.MoneyAccountService
|
accountService *service.MoneyAccountService
|
||||||
|
methodService *service.PaymentMethodService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService) *SpaceHandler {
|
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService, pms *service.PaymentMethodService) *SpaceHandler {
|
||||||
return &SpaceHandler{
|
return &SpaceHandler{
|
||||||
spaceService: ss,
|
spaceService: ss,
|
||||||
tagService: ts,
|
tagService: ts,
|
||||||
|
|
@ -36,6 +38,7 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
|
||||||
expenseService: es,
|
expenseService: es,
|
||||||
inviteService: is,
|
inviteService: is,
|
||||||
accountService: mas,
|
accountService: mas,
|
||||||
|
methodService: pms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,7 +401,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
page = p
|
page = p
|
||||||
}
|
}
|
||||||
|
|
||||||
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page)
|
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
|
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -433,7 +436,14 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, totalAllocated, tags, listsWithItems, page, totalPages))
|
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" {
|
if r.URL.Query().Get("created") == "true" {
|
||||||
ui.Render(w, r, toast.Toast(toast.Props{
|
ui.Render(w, r, toast.Toast(toast.Props{
|
||||||
|
|
@ -528,6 +538,12 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
processedTags[tagName] = true
|
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
|
// Parse linked shopping list items
|
||||||
itemIDs := r.Form["item_ids"]
|
itemIDs := r.Form["item_ids"]
|
||||||
itemAction := r.FormValue("item_action")
|
itemAction := r.FormValue("item_action")
|
||||||
|
|
@ -538,14 +554,15 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
dto := service.CreateExpenseDTO{
|
dto := service.CreateExpenseDTO{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Description: description,
|
Description: description,
|
||||||
Amount: amountCents,
|
Amount: amountCents,
|
||||||
Type: expenseType,
|
Type: expenseType,
|
||||||
Date: date,
|
Date: date,
|
||||||
TagIDs: finalTagIDs,
|
TagIDs: finalTagIDs,
|
||||||
ItemIDs: itemIDs,
|
ItemIDs: itemIDs,
|
||||||
|
PaymentMethodID: paymentMethodID,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = h.expenseService.CreateExpense(dto)
|
_, err = h.expenseService.CreateExpense(dto)
|
||||||
|
|
@ -587,7 +604,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the full paginated list for page 1 so the new expense appears
|
// Return the full paginated list for page 1 so the new expense appears
|
||||||
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, 1)
|
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get paginated expenses after create", "error", err, "space_id", spaceID)
|
slog.Error("failed to get paginated expenses after create", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -684,14 +701,21 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
processedTags[tagName] = true
|
processedTags[tagName] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse payment_method_id
|
||||||
|
var paymentMethodID *string
|
||||||
|
if pmid := r.FormValue("payment_method_id"); pmid != "" {
|
||||||
|
paymentMethodID = &pmid
|
||||||
|
}
|
||||||
|
|
||||||
dto := service.UpdateExpenseDTO{
|
dto := service.UpdateExpenseDTO{
|
||||||
ID: expenseID,
|
ID: expenseID,
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
Description: description,
|
Description: description,
|
||||||
Amount: amountCents,
|
Amount: amountCents,
|
||||||
Type: expenseType,
|
Type: expenseType,
|
||||||
Date: date,
|
Date: date,
|
||||||
TagIDs: finalTagIDs,
|
TagIDs: finalTagIDs,
|
||||||
|
PaymentMethodID: paymentMethodID,
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedExpense, err := h.expenseService.UpdateExpense(dto)
|
updatedExpense, err := h.expenseService.UpdateExpense(dto)
|
||||||
|
|
@ -702,9 +726,11 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID})
|
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID})
|
||||||
expWithTags := &model.ExpenseWithTags{
|
methodsMap, _ := h.expenseService.GetPaymentMethodsByExpenseIDs([]string{updatedExpense.ID})
|
||||||
Expense: *updatedExpense,
|
expWithTagsAndMethod := &model.ExpenseWithTagsAndMethod{
|
||||||
Tags: tagsMap[updatedExpense.ID],
|
Expense: *updatedExpense,
|
||||||
|
Tags: tagsMap[updatedExpense.ID],
|
||||||
|
PaymentMethod: methodsMap[updatedExpense.ID],
|
||||||
}
|
}
|
||||||
|
|
||||||
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
|
@ -719,7 +745,8 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
balance -= totalAllocated
|
balance -= totalAllocated
|
||||||
|
|
||||||
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTags, balance, totalAllocated))
|
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||||
|
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -839,14 +866,15 @@ func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
|
||||||
page = p
|
page = p
|
||||||
}
|
}
|
||||||
|
|
||||||
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page)
|
expenses, totalPages, err := h.expenseService.GetExpensesWithTagsAndMethodsForSpacePaginated(spaceID, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
|
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, page, totalPages))
|
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
|
||||||
|
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, page, totalPages))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -1339,6 +1367,118 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
|
||||||
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
|
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Payment Methods ---
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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 *SpaceHandler) 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 *SpaceHandler) CreatePaymentMethod(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
user := ctxkeys.User(r.Context())
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
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)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, paymentmethod.MethodItem(spaceID, method))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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 {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
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)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, paymentmethod.MethodItem(spaceID, updatedMethod))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
|
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
|
||||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
lists, err := h.listService.GetListsForSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,16 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Expense struct {
|
type Expense struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
SpaceID string `db:"space_id"`
|
SpaceID string `db:"space_id"`
|
||||||
CreatedBy string `db:"created_by"`
|
CreatedBy string `db:"created_by"`
|
||||||
Description string `db:"description"`
|
Description string `db:"description"`
|
||||||
AmountCents int `db:"amount_cents"`
|
AmountCents int `db:"amount_cents"`
|
||||||
Type ExpenseType `db:"type"`
|
Type ExpenseType `db:"type"`
|
||||||
Date time.Time `db:"date"`
|
Date time.Time `db:"date"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
PaymentMethodID *string `db:"payment_method_id"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExpenseWithTags struct {
|
type ExpenseWithTags struct {
|
||||||
|
|
@ -26,6 +27,12 @@ type ExpenseWithTags struct {
|
||||||
Tags []*Tag
|
Tags []*Tag
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExpenseWithTagsAndMethod struct {
|
||||||
|
Expense
|
||||||
|
Tags []*Tag
|
||||||
|
PaymentMethod *PaymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
type ExpenseTag struct {
|
type ExpenseTag struct {
|
||||||
ExpenseID string `db:"expense_id"`
|
ExpenseID string `db:"expense_id"`
|
||||||
TagID string `db:"tag_id"`
|
TagID string `db:"tag_id"`
|
||||||
|
|
|
||||||
21
internal/model/payment_method.go
Normal file
21
internal/model/payment_method.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type PaymentMethodType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentMethodTypeCredit PaymentMethodType = "credit"
|
||||||
|
PaymentMethodTypeDebit PaymentMethodType = "debit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PaymentMethod struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Type PaymentMethodType `db:"type"`
|
||||||
|
LastFour *string `db:"last_four"`
|
||||||
|
CreatedBy string `db:"created_by"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ type ExpenseRepository interface {
|
||||||
CountBySpaceID(spaceID string) (int, error)
|
CountBySpaceID(spaceID string) (int, error)
|
||||||
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
|
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
|
||||||
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
|
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
|
||||||
|
GetPaymentMethodsByExpenseIDs(expenseIDs []string) (map[string]*model.PaymentMethod, error)
|
||||||
Update(expense *model.Expense, tagIDs []string) error
|
Update(expense *model.Expense, tagIDs []string) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +42,9 @@ func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, item
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
// Insert Expense
|
// Insert Expense
|
||||||
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, created_at, updated_at)
|
queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);`
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`
|
||||||
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.CreatedAt, expense.UpdatedAt)
|
_, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +174,49 @@ func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string
|
||||||
return result, nil
|
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 {
|
func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
|
||||||
tx, err := r.db.Beginx()
|
tx, err := r.db.Beginx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -180,8 +224,8 @@ func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) erro
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;`
|
query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, payment_method_id = $5, updated_at = $6 WHERE id = $7;`
|
||||||
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID)
|
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.PaymentMethodID, expense.UpdatedAt, expense.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
internal/repository/payment_method.go
Normal file
83
internal/repository/payment_method.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
home := handler.NewHomeHandler()
|
home := handler.NewHomeHandler()
|
||||||
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
|
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
|
||||||
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
|
||||||
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService)
|
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -151,6 +151,23 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
deleteTransferWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTransferHandler)
|
deleteTransferWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTransferHandler)
|
||||||
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", deleteTransferWithAccess)
|
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", deleteTransferWithAccess)
|
||||||
|
|
||||||
|
// Payment Method routes
|
||||||
|
methodsPageHandler := middleware.RequireAuth(space.PaymentMethodsPage)
|
||||||
|
methodsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(methodsPageHandler)
|
||||||
|
mux.Handle("GET /app/spaces/{spaceID}/payment-methods", methodsPageWithAccess)
|
||||||
|
|
||||||
|
createMethodHandler := middleware.RequireAuth(space.CreatePaymentMethod)
|
||||||
|
createMethodWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createMethodHandler)
|
||||||
|
mux.Handle("POST /app/spaces/{spaceID}/payment-methods", createMethodWithAccess)
|
||||||
|
|
||||||
|
updateMethodHandler := middleware.RequireAuth(space.UpdatePaymentMethod)
|
||||||
|
updateMethodWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateMethodHandler)
|
||||||
|
mux.Handle("PATCH /app/spaces/{spaceID}/payment-methods/{methodID}", updateMethodWithAccess)
|
||||||
|
|
||||||
|
deleteMethodHandler := middleware.RequireAuth(space.DeletePaymentMethod)
|
||||||
|
deleteMethodWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteMethodHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/payment-methods/{methodID}", deleteMethodWithAccess)
|
||||||
|
|
||||||
// Component routes (HTMX updates)
|
// Component routes (HTMX updates)
|
||||||
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
|
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
|
||||||
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
|
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
|
||||||
|
|
|
||||||
|
|
@ -10,24 +10,26 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateExpenseDTO struct {
|
type CreateExpenseDTO struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
UserID string
|
UserID string
|
||||||
Description string
|
Description string
|
||||||
Amount int
|
Amount int
|
||||||
Type model.ExpenseType
|
Type model.ExpenseType
|
||||||
Date time.Time
|
Date time.Time
|
||||||
TagIDs []string
|
TagIDs []string
|
||||||
ItemIDs []string
|
ItemIDs []string
|
||||||
|
PaymentMethodID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateExpenseDTO struct {
|
type UpdateExpenseDTO struct {
|
||||||
ID string
|
ID string
|
||||||
SpaceID string
|
SpaceID string
|
||||||
Description string
|
Description string
|
||||||
Amount int
|
Amount int
|
||||||
Type model.ExpenseType
|
Type model.ExpenseType
|
||||||
Date time.Time
|
Date time.Time
|
||||||
TagIDs []string
|
TagIDs []string
|
||||||
|
PaymentMethodID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpensesPerPage = 25
|
const ExpensesPerPage = 25
|
||||||
|
|
@ -52,15 +54,16 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expense := &model.Expense{
|
expense := &model.Expense{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
SpaceID: dto.SpaceID,
|
SpaceID: dto.SpaceID,
|
||||||
CreatedBy: dto.UserID,
|
CreatedBy: dto.UserID,
|
||||||
Description: dto.Description,
|
Description: dto.Description,
|
||||||
AmountCents: dto.Amount,
|
AmountCents: dto.Amount,
|
||||||
Type: dto.Type,
|
Type: dto.Type,
|
||||||
Date: dto.Date,
|
Date: dto.Date,
|
||||||
CreatedAt: now,
|
PaymentMethodID: dto.PaymentMethodID,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs)
|
err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs)
|
||||||
|
|
@ -166,6 +169,59 @@ func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, pa
|
||||||
return result, totalPages, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * 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) {
|
func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) {
|
||||||
return s.expenseRepo.GetByID(id)
|
return s.expenseRepo.GetByID(id)
|
||||||
}
|
}
|
||||||
|
|
@ -191,6 +247,7 @@ func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, er
|
||||||
existing.AmountCents = dto.Amount
|
existing.AmountCents = dto.Amount
|
||||||
existing.Type = dto.Type
|
existing.Type = dto.Type
|
||||||
existing.Date = dto.Date
|
existing.Date = dto.Date
|
||||||
|
existing.PaymentMethodID = dto.PaymentMethodID
|
||||||
existing.UpdatedAt = time.Now()
|
existing.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
|
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
|
||||||
|
|
|
||||||
109
internal/service/payment_method.go
Normal file
109
internal/service/payment_method.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
"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/input"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
"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/radio"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||||
)
|
)
|
||||||
|
|
@ -19,6 +20,7 @@ type AddExpenseFormProps struct {
|
||||||
Space *model.Space
|
Space *model.Space
|
||||||
Tags []*model.Tag
|
Tags []*model.Tag
|
||||||
ListsWithItems []model.ListWithUncheckedItems
|
ListsWithItems []model.ListWithUncheckedItems
|
||||||
|
PaymentMethods []*model.PaymentMethod
|
||||||
DialogID string // which dialog to close on success
|
DialogID string // which dialog to close on success
|
||||||
FromOverview bool // if true, POSTs with ?from=overview; server redirects to expenses page
|
FromOverview bool // if true, POSTs with ?from=overview; server redirects to expenses page
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +160,8 @@ templ AddExpenseForm(props AddExpenseFormProps) {
|
||||||
Attributes: templ.Attributes{"list": "available-tags"},
|
Attributes: templ.Attributes{"list": "available-tags"},
|
||||||
})
|
})
|
||||||
</div>
|
</div>
|
||||||
|
// Payment Method
|
||||||
|
@paymentmethod.MethodSelector(props.PaymentMethods, nil)
|
||||||
// Shopping list items selector
|
// Shopping list items selector
|
||||||
@ItemSelectorSection(props.ListsWithItems, false)
|
@ItemSelectorSection(props.ListsWithItems, false)
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|
@ -168,7 +172,7 @@ templ AddExpenseForm(props AddExpenseFormProps) {
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
|
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||||
{{ editDialogID := "edit-expense-" + exp.ID }}
|
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||||
{{ tagValues := make([]string, len(exp.Tags)) }}
|
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||||
for i, t := range exp.Tags {
|
for i, t := range exp.Tags {
|
||||||
|
|
@ -260,6 +264,8 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
|
||||||
Placeholder: "Add tags (press enter)",
|
Placeholder: "Add tags (press enter)",
|
||||||
})
|
})
|
||||||
</div>
|
</div>
|
||||||
|
// Payment Method
|
||||||
|
@paymentmethod.MethodSelector(methods, exp.PaymentMethodID)
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
Save
|
Save
|
||||||
|
|
|
||||||
268
internal/ui/components/paymentmethod/paymentmethod.templ
Normal file
268
internal/ui/components/paymentmethod/paymentmethod.templ
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
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.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ MethodSelector(methods []*model.PaymentMethod, selectedMethodID *string) {
|
||||||
|
<div>
|
||||||
|
@label.Label(label.Props{For: "method-select"}) {
|
||||||
|
Payment Method
|
||||||
|
}
|
||||||
|
<select
|
||||||
|
name="payment_method_id"
|
||||||
|
id="method-select"
|
||||||
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="">Cash</option>
|
||||||
|
for _, m := range methods {
|
||||||
|
<option
|
||||||
|
value={ m.ID }
|
||||||
|
if selectedMethodID != nil && *selectedMethodID == m.ID {
|
||||||
|
selected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
if m.LastFour != nil {
|
||||||
|
{ m.Name } (*{ *m.LastFour })
|
||||||
|
} else {
|
||||||
|
{ m.Name }
|
||||||
|
}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,16 @@ templ Space(title string, space *model.Space) {
|
||||||
<span>Accounts</span>
|
<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.MenuItem() {
|
||||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
Href: "/app/spaces/" + space.ID + "/lists",
|
Href: "/app/spaces/" + space.ID + "/lists",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, allocated int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, currentPage, totalPages int) {
|
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) {
|
||||||
@layouts.Space("Expenses", space) {
|
@layouts.Space("Expenses", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
|
@ -37,6 +37,7 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b
|
||||||
Space: space,
|
Space: space,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
ListsWithItems: listsWithItems,
|
ListsWithItems: listsWithItems,
|
||||||
|
PaymentMethods: methods,
|
||||||
DialogID: "add-expense-dialog",
|
DialogID: "add-expense-dialog",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -47,21 +48,21 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b
|
||||||
// List of expenses
|
// List of expenses
|
||||||
<div class="border rounded-lg">
|
<div class="border rounded-lg">
|
||||||
<div id="expenses-list-wrapper">
|
<div id="expenses-list-wrapper">
|
||||||
@ExpensesListContent(space.ID, expenses, currentPage, totalPages)
|
@ExpensesListContent(space.ID, expenses, methods, currentPage, totalPages)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags, currentPage, totalPages int) {
|
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, currentPage, totalPages int) {
|
||||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||||
<div id="expenses-list" class="divide-y">
|
<div id="expenses-list" class="divide-y">
|
||||||
if len(expenses) == 0 {
|
if len(expenses) == 0 {
|
||||||
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
||||||
}
|
}
|
||||||
for _, exp := range expenses {
|
for _, exp := range expenses {
|
||||||
@ExpenseListItem(spaceID, exp)
|
@ExpenseListItem(spaceID, exp, methods)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
if totalPages > 1 {
|
if totalPages > 1 {
|
||||||
|
|
@ -108,11 +109,22 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags, cur
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
|
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
|
||||||
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-medium">{ exp.Description }</p>
|
<p class="font-medium">{ exp.Description }</p>
|
||||||
<p class="text-sm text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
|
<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 {
|
if len(exp.Tags) > 0 {
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
for _, t := range exp.Tags {
|
for _, t := range exp.Tags {
|
||||||
|
|
@ -149,7 +161,7 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
|
||||||
Update the details of this transaction.
|
Update the details of this transaction.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@expense.EditExpenseForm(spaceID, exp)
|
@expense.EditExpenseForm(spaceID, exp, methods)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Delete button
|
// Delete button
|
||||||
|
|
@ -191,12 +203,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTags, balance int, allocated int, currentPage, totalPages int) {
|
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, currentPage, totalPages int) {
|
||||||
@ExpensesListContent(spaceID, expenses, currentPage, totalPages)
|
@ExpensesListContent(spaceID, expenses, nil, currentPage, totalPages)
|
||||||
@expense.BalanceCard(spaceID, balance, allocated, true)
|
@expense.BalanceCard(spaceID, balance, allocated, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int, allocated int) {
|
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod) {
|
||||||
@ExpenseListItem(spaceID, exp)
|
@ExpenseListItem(spaceID, exp, methods)
|
||||||
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
|
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
internal/ui/pages/app_space_payment_methods.templ
Normal file
45
internal/ui/pages/app_space_payment_methods.templ
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue