budgit/internal/handler/account_handler.go
juancwu 89c5d76e5e
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s
Merge branch 'fix/calculation-accuracy' into main
Combines the decimal migration (int cents → decimal.Decimal via
shopspring/decimal) with main's handler refactor (split space.go into
domain handlers, WithTx/Paginate helpers, recurring deposit removal).

- Repository layer: WithTx pattern + decimal column names/types
- Handler layer: decimal arithmetic (.Sub/.Add) instead of int operators
- Models: deprecated amount_cents fields kept for SELECT * compatibility
- INSERT statements: old columns set to literal 0 for NOT NULL constraints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:48:40 -04:00

363 lines
12 KiB
Go

package handler
import (
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type AccountHandler struct {
spaceService *service.SpaceService
accountService *service.MoneyAccountService
expenseService *service.ExpenseService
}
func NewAccountHandler(ss *service.SpaceService, mas *service.MoneyAccountService, es *service.ExpenseService) *AccountHandler {
return &AccountHandler{
spaceService: ss,
accountService: mas,
expenseService: es,
}
}
func (h *AccountHandler) getAccountForSpace(w http.ResponseWriter, spaceID, accountID string) *model.MoneyAccount {
account, err := h.accountService.GetAccount(accountID)
if err != nil {
http.Error(w, "Account not found", http.StatusNotFound)
return nil
}
if account.SpaceID != spaceID {
http.Error(w, "Not Found", http.StatusNotFound)
return nil
}
return account
}
func (h *AccountHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
http.Error(w, "Space not found", http.StatusNotFound)
return
}
accounts, err := h.accountService.GetAccountsForSpace(spaceID)
if err != nil {
slog.Error("failed to get accounts for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
availableBalance := totalBalance.Sub(totalAllocated)
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
if err != nil {
slog.Error("failed to get transfers", "error", err, "space_id", spaceID)
transfers = nil
totalPages = 1
}
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, transfers, 1, totalPages))
}
func (h *AccountHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
user := ctxkeys.User(r.Context())
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
if name == "" {
ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity)
return
}
account, err := h.accountService.CreateAccount(service.CreateMoneyAccountDTO{
SpaceID: spaceID,
Name: name,
CreatedBy: user.ID,
})
if err != nil {
slog.Error("failed to create account", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
Balance: decimal.Zero,
}
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
}
func (h *AccountHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
name := r.FormValue("name")
if name == "" {
ui.RenderError(w, r, "Account name is required", http.StatusUnprocessableEntity)
return
}
updatedAccount, err := h.accountService.UpdateAccount(service.UpdateMoneyAccountDTO{
ID: accountID,
Name: name,
})
if err != nil {
slog.Error("failed to update account", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
balance, err := h.accountService.GetAccountBalance(accountID)
if err != nil {
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *updatedAccount,
Balance: balance,
}
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance))
}
func (h *AccountHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
err := h.accountService.DeleteAccount(accountID)
if err != nil {
slog.Error("failed to delete account", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Return updated balance summary via OOB swap
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
}
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Account deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *AccountHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
user := ctxkeys.User(r.Context())
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
if err := r.ParseForm(); err != nil {
ui.RenderError(w, r, "Bad Request", http.StatusUnprocessableEntity)
return
}
amountStr := r.FormValue("amount")
direction := model.TransferDirection(r.FormValue("direction"))
note := r.FormValue("note")
amountDecimal, err := decimal.NewFromString(amountStr)
if err != nil || amountDecimal.LessThanOrEqual(decimal.Zero) {
ui.RenderError(w, r, "Invalid amount", http.StatusUnprocessableEntity)
return
}
amount := amountDecimal
// Calculate available space balance for deposit validation
totalBalance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
totalAllocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
if err != nil {
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
availableBalance := totalBalance.Sub(totalAllocated)
// Validate balance limits before creating transfer
if direction == model.TransferDirectionDeposit && amount.GreaterThan(availableBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient available balance. You can deposit up to %s.", model.FormatMoney(availableBalance)), http.StatusUnprocessableEntity)
return
}
if direction == model.TransferDirectionWithdrawal {
acctBalance, err := h.accountService.GetAccountBalance(accountID)
if err != nil {
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if amount.GreaterThan(acctBalance) {
ui.RenderError(w, r, fmt.Sprintf("Insufficient account balance. You can withdraw up to %s.", model.FormatMoney(acctBalance)), http.StatusUnprocessableEntity)
return
}
}
_, err = h.accountService.CreateTransfer(service.CreateTransferDTO{
AccountID: accountID,
Amount: amount,
Direction: direction,
Note: note,
CreatedBy: user.ID,
}, availableBalance)
if err != nil {
slog.Error("failed to create transfer", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Return updated account card + OOB balance summary
accountBalance, err := h.accountService.GetAccountBalance(accountID)
if err != nil {
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
Balance: accountBalance,
}
// Recalculate available balance after transfer
totalAllocated, _ = h.accountService.GetTotalAllocatedForSpace(spaceID)
newAvailable := totalBalance.Sub(totalAllocated)
w.Header().Set("HX-Trigger", "transferSuccess")
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, true))
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
}
func (h *AccountHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
if h.getAccountForSpace(w, spaceID, accountID) == nil {
return
}
transferID := r.PathValue("transferID")
err := h.accountService.DeleteTransfer(transferID)
if err != nil {
slog.Error("failed to delete transfer", "error", err, "transfer_id", transferID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Return updated account card + OOB balance summary
accountBalance, err := h.accountService.GetAccountBalance(accountID)
if err != nil {
slog.Error("failed to get account balance", "error", err, "account_id", accountID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
account, _ := h.accountService.GetAccount(accountID)
acctWithBalance := model.MoneyAccountWithBalance{
MoneyAccount: *account,
Balance: accountBalance,
}
totalBalance, _ := h.expenseService.GetBalanceForSpace(spaceID)
totalAllocated, _ := h.accountService.GetTotalAllocatedForSpace(spaceID)
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance.Sub(totalAllocated), true))
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Transfer deleted",
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
}
func (h *AccountHandler) GetTransferHistory(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, page)
if err != nil {
slog.Error("failed to get transfers", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, page, totalPages, false))
}