feat: create accounts to partition money
This commit is contained in:
parent
fb1a038821
commit
d6f6790c4d
11 changed files with 1026 additions and 4 deletions
|
|
@ -22,6 +22,7 @@ type App struct {
|
||||||
ShoppingListService *service.ShoppingListService
|
ShoppingListService *service.ShoppingListService
|
||||||
ExpenseService *service.ExpenseService
|
ExpenseService *service.ExpenseService
|
||||||
InviteService *service.InviteService
|
InviteService *service.InviteService
|
||||||
|
MoneyAccountService *service.MoneyAccountService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
|
|
@ -47,6 +48,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
listItemRepository := repository.NewListItemRepository(database)
|
listItemRepository := repository.NewListItemRepository(database)
|
||||||
expenseRepository := repository.NewExpenseRepository(database)
|
expenseRepository := repository.NewExpenseRepository(database)
|
||||||
invitationRepository := repository.NewInvitationRepository(database)
|
invitationRepository := repository.NewInvitationRepository(database)
|
||||||
|
moneyAccountRepository := repository.NewMoneyAccountRepository(database)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
userService := service.NewUserService(userRepository)
|
userService := service.NewUserService(userRepository)
|
||||||
|
|
@ -74,6 +76,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
||||||
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)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
|
@ -87,6 +90,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||||
ShoppingListService: shoppingListService,
|
ShoppingListService: shoppingListService,
|
||||||
ExpenseService: expenseService,
|
ExpenseService: expenseService,
|
||||||
InviteService: inviteService,
|
InviteService: inviteService,
|
||||||
|
MoneyAccountService: moneyAccountService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,8 @@ func (c *Config) Sanitized() *Config {
|
||||||
Port: c.Port,
|
Port: c.Port,
|
||||||
AppTagline: c.AppTagline,
|
AppTagline: c.AppTagline,
|
||||||
|
|
||||||
MailerEmailFrom: c.MailerEmailFrom,
|
MailerEmailFrom: c.MailerEmailFrom,
|
||||||
SupportEmail: c.SupportEmail,
|
SupportEmail: c.SupportEmail,
|
||||||
GoogleMeasuringID: c.GoogleMeasuringID,
|
GoogleMeasuringID: c.GoogleMeasuringID,
|
||||||
|
|
||||||
Version: c.Version,
|
Version: c.Version,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE IF NOT EXISTS money_accounts (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
space_id TEXT NOT NULL,
|
||||||
|
name 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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS account_transfers (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
amount_cents INTEGER NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK (direction IN ('deposit', 'withdrawal')),
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES money_accounts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_money_accounts_space_id ON money_accounts(space_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_transfers_account_id ON account_transfers(account_id);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP INDEX IF EXISTS idx_account_transfers_account_id;
|
||||||
|
DROP INDEX IF EXISTS idx_money_accounts_space_id;
|
||||||
|
DROP TABLE IF EXISTS account_transfers;
|
||||||
|
DROP TABLE IF EXISTS money_accounts;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -11,6 +12,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
"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/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"
|
||||||
|
|
@ -23,15 +25,17 @@ type SpaceHandler struct {
|
||||||
listService *service.ShoppingListService
|
listService *service.ShoppingListService
|
||||||
expenseService *service.ExpenseService
|
expenseService *service.ExpenseService
|
||||||
inviteService *service.InviteService
|
inviteService *service.InviteService
|
||||||
|
accountService *service.MoneyAccountService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService) *SpaceHandler {
|
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, mas *service.MoneyAccountService) *SpaceHandler {
|
||||||
return &SpaceHandler{
|
return &SpaceHandler{
|
||||||
spaceService: ss,
|
spaceService: ss,
|
||||||
tagService: ts,
|
tagService: ts,
|
||||||
listService: sls,
|
listService: sls,
|
||||||
expenseService: es,
|
expenseService: es,
|
||||||
inviteService: is,
|
inviteService: is,
|
||||||
|
accountService: mas,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1006,6 +1010,293 @@ func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request)
|
||||||
ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites))
|
ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Money Accounts ---
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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 *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
space, err := h.spaceService.GetSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Space not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, err := h.accountService.GetAccountsForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get balance for space", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
availableBalance := totalBalance - totalAllocated
|
||||||
|
|
||||||
|
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) CreateAccount(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")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Account name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.accountService.CreateAccount(service.CreateMoneyAccountDTO{
|
||||||
|
SpaceID: spaceID,
|
||||||
|
Name: name,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create account", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acctWithBalance := model.MoneyAccountWithBalance{
|
||||||
|
MoneyAccount: *account,
|
||||||
|
BalanceCents: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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 {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Account name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedAccount, err := h.accountService.UpdateAccount(service.UpdateMoneyAccountDTO{
|
||||||
|
ID: accountID,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to update account", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := h.accountService.GetAccountBalance(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acctWithBalance := model.MoneyAccountWithBalance{
|
||||||
|
MoneyAccount: *updatedAccount,
|
||||||
|
BalanceCents: balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
accountID := r.PathValue("accountID")
|
||||||
|
|
||||||
|
if h.getAccountForSpace(w, spaceID, accountID) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.accountService.DeleteAccount(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to delete account", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated balance summary via OOB swap
|
||||||
|
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||||
|
}
|
||||||
|
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) 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 {
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amountStr := r.FormValue("amount")
|
||||||
|
direction := model.TransferDirection(r.FormValue("direction"))
|
||||||
|
note := r.FormValue("note")
|
||||||
|
|
||||||
|
amountFloat, err := strconv.ParseFloat(amountStr, 64)
|
||||||
|
if err != nil || amountFloat <= 0 {
|
||||||
|
http.Error(w, "Invalid amount", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
amountCents := int(amountFloat * 100)
|
||||||
|
|
||||||
|
// Calculate available space balance for deposit validation
|
||||||
|
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
availableBalance := totalBalance - totalAllocated
|
||||||
|
|
||||||
|
// Validate balance limits before creating transfer
|
||||||
|
if direction == model.TransferDirectionDeposit && amountCents > availableBalance {
|
||||||
|
fmt.Fprintf(w, "Insufficient available balance. You can deposit up to $%.2f.", float64(availableBalance)/100.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if direction == model.TransferDirectionWithdrawal {
|
||||||
|
acctBalance, err := h.accountService.GetAccountBalance(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if amountCents > acctBalance {
|
||||||
|
fmt.Fprintf(w, "Insufficient account balance. You can withdraw up to $%.2f.", float64(acctBalance)/100.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.accountService.CreateTransfer(service.CreateTransferDTO{
|
||||||
|
AccountID: accountID,
|
||||||
|
Amount: amountCents,
|
||||||
|
Direction: direction,
|
||||||
|
Note: note,
|
||||||
|
CreatedBy: user.ID,
|
||||||
|
}, availableBalance)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create transfer", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated account card + OOB balance summary
|
||||||
|
accountBalance, err := h.accountService.GetAccountBalance(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, _ := h.accountService.GetAccount(accountID)
|
||||||
|
acctWithBalance := model.MoneyAccountWithBalance{
|
||||||
|
MoneyAccount: *account,
|
||||||
|
BalanceCents: accountBalance,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate available balance after transfer
|
||||||
|
totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
newAvailable := totalBalance - totalAllocated
|
||||||
|
|
||||||
|
w.Header().Set("HX-Trigger", "transferSuccess")
|
||||||
|
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
||||||
|
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := r.PathValue("spaceID")
|
||||||
|
accountID := r.PathValue("accountID")
|
||||||
|
|
||||||
|
if h.getAccountForSpace(w, spaceID, accountID) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transferID := r.PathValue("transferID")
|
||||||
|
err := h.accountService.DeleteTransfer(transferID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to delete transfer", "error", err, "transfer_id", transferID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated account card + OOB balance summary
|
||||||
|
accountBalance, err := h.accountService.GetAccountBalance(accountID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, _ := h.accountService.GetAccount(accountID)
|
||||||
|
acctWithBalance := model.MoneyAccountWithBalance{
|
||||||
|
MoneyAccount: *account,
|
||||||
|
BalanceCents: accountBalance,
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
|
totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
|
||||||
|
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
||||||
|
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
||||||
34
internal/model/money_account.go
Normal file
34
internal/model/money_account.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type TransferDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransferDirectionDeposit TransferDirection = "deposit"
|
||||||
|
TransferDirectionWithdrawal TransferDirection = "withdrawal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MoneyAccount struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
CreatedBy string `db:"created_by"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountTransfer struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
AccountID string `db:"account_id"`
|
||||||
|
AmountCents int `db:"amount_cents"`
|
||||||
|
Direction TransferDirection `db:"direction"`
|
||||||
|
Note string `db:"note"`
|
||||||
|
CreatedBy string `db:"created_by"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MoneyAccountWithBalance struct {
|
||||||
|
MoneyAccount
|
||||||
|
BalanceCents int
|
||||||
|
}
|
||||||
137
internal/repository/money_account.go
Normal file
137
internal/repository/money_account.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) (int, error)
|
||||||
|
GetTotalAllocatedForSpace(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_cents, direction, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7);`
|
||||||
|
_, err := r.db.Exec(query, transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, 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) (int, error) {
|
||||||
|
var balance int
|
||||||
|
query := `SELECT COALESCE(SUM(CASE WHEN direction = 'deposit' THEN amount_cents ELSE -amount_cents 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) (int, error) {
|
||||||
|
var total int
|
||||||
|
query := `SELECT COALESCE(SUM(CASE WHEN t.direction = 'deposit' THEN t.amount_cents ELSE -t.amount_cents 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -126,6 +126,31 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler)
|
deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler)
|
||||||
mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess)
|
mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess)
|
||||||
|
|
||||||
|
// Money Account routes
|
||||||
|
accountsPageHandler := middleware.RequireAuth(space.AccountsPage)
|
||||||
|
accountsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(accountsPageHandler)
|
||||||
|
mux.Handle("GET /app/spaces/{spaceID}/accounts", accountsPageWithAccess)
|
||||||
|
|
||||||
|
createAccountHandler := middleware.RequireAuth(space.CreateAccount)
|
||||||
|
createAccountWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createAccountHandler)
|
||||||
|
mux.Handle("POST /app/spaces/{spaceID}/accounts", createAccountWithAccess)
|
||||||
|
|
||||||
|
updateAccountHandler := middleware.RequireAuth(space.UpdateAccount)
|
||||||
|
updateAccountWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateAccountHandler)
|
||||||
|
mux.Handle("PATCH /app/spaces/{spaceID}/accounts/{accountID}", updateAccountWithAccess)
|
||||||
|
|
||||||
|
deleteAccountHandler := middleware.RequireAuth(space.DeleteAccount)
|
||||||
|
deleteAccountWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteAccountHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}", deleteAccountWithAccess)
|
||||||
|
|
||||||
|
createTransferHandler := middleware.RequireAuth(space.CreateTransfer)
|
||||||
|
createTransferWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createTransferHandler)
|
||||||
|
mux.Handle("POST /app/spaces/{spaceID}/accounts/{accountID}/transfers", createTransferWithAccess)
|
||||||
|
|
||||||
|
deleteTransferHandler := middleware.RequireAuth(space.DeleteTransfer)
|
||||||
|
deleteTransferWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTransferHandler)
|
||||||
|
mux.Handle("DELETE /app/spaces/{spaceID}/accounts/{accountID}/transfers/{transferID}", deleteTransferWithAccess)
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
|
||||||
173
internal/service/money_account.go
Normal file
173
internal/service/money_account.go
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
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 CreateMoneyAccountDTO struct {
|
||||||
|
SpaceID string
|
||||||
|
Name string
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateMoneyAccountDTO struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateTransferDTO struct {
|
||||||
|
AccountID string
|
||||||
|
Amount int
|
||||||
|
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,
|
||||||
|
BalanceCents: 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 int) (*model.AccountTransfer, error) {
|
||||||
|
if dto.Amount <= 0 {
|
||||||
|
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 > 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 > accountBalance {
|
||||||
|
return nil, fmt.Errorf("insufficient account balance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := &model.AccountTransfer{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
AccountID: dto.AccountID,
|
||||||
|
AmountCents: 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) (int, error) {
|
||||||
|
return s.accountRepo.GetAccountBalance(accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (int, error) {
|
||||||
|
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
|
||||||
|
}
|
||||||
265
internal/ui/components/moneyaccount/moneyaccount.templ
Normal file
265
internal/ui/components/moneyaccount/moneyaccount.templ
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
package moneyaccount
|
||||||
|
|
||||||
|
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/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 BalanceSummaryCard(spaceID string, totalBalance int, availableBalance int, 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 < 0) }>
|
||||||
|
{ fmt.Sprintf("$%.2f", float64(totalBalance)/100.0) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Allocated</p>
|
||||||
|
<p class="text-xl font-bold">
|
||||||
|
{ fmt.Sprintf("$%.2f", float64(totalBalance-availableBalance)/100.0) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Available</p>
|
||||||
|
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance < 0) }>
|
||||||
|
{ fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
|
||||||
|
</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.BalanceCents < 0) }>
|
||||||
|
{ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) }
|
||||||
|
</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.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
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.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
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.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
|
if direction == model.TransferDirectionDeposit {
|
||||||
|
Deposit
|
||||||
|
} else {
|
||||||
|
Withdraw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,16 @@ templ Space(title string, space *model.Space) {
|
||||||
<span>Expenses</span>
|
<span>Expenses</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.MenuItem() {
|
||||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||||
Href: "/app/spaces/" + space.ID + "/lists",
|
Href: "/app/spaces/" + space.ID + "/lists",
|
||||||
|
|
|
||||||
46
internal/ui/pages/app_space_accounts.templ
Normal file
46
internal/ui/pages/app_space_accounts.templ
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance 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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue