feat: show account transfer history
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m50s

This commit is contained in:
juancwu 2026-02-20 16:43:25 +00:00
commit e10186fd7a
7 changed files with 223 additions and 2 deletions

View file

@ -1293,7 +1293,14 @@ func (h *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
recurringDeposits = nil
}
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, recurringDeposits))
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, recurringDeposits, transfers, 1, totalPages))
}
func (h *SpaceHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
@ -1502,6 +1509,9 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
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 *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
@ -1539,6 +1549,10 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-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,
@ -1548,6 +1562,26 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
}))
}
// --- Transfer History ---
func (h *SpaceHandler) 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))
}
// --- Recurring Deposits ---
func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit {

View file

@ -33,3 +33,8 @@ type MoneyAccountWithBalance struct {
MoneyAccount
BalanceCents int
}
type AccountTransferWithAccount struct {
AccountTransfer
AccountName string `db:"account_name"`
}

View file

@ -27,6 +27,9 @@ type MoneyAccountRepository interface {
GetAccountBalance(accountID string) (int, error)
GetTotalAllocatedForSpace(spaceID string) (int, error)
GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error)
CountTransfersBySpaceID(spaceID string) (int, error)
}
type moneyAccountRepository struct {
@ -135,3 +138,28 @@ func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int,
err := r.db.Get(&total, query, spaceID)
return total, err
}
func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) {
var transfers []*model.AccountTransferWithAccount
query := `SELECT t.id, t.account_id, t.amount_cents, t.direction, t.note,
t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name
FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1
ORDER BY t.created_at DESC
LIMIT $2 OFFSET $3;`
err := r.db.Select(&transfers, query, spaceID, limit, offset)
if err != nil {
return nil, err
}
return transfers, nil
}
func (r *moneyAccountRepository) CountTransfersBySpaceID(spaceID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM account_transfers t
JOIN money_accounts a ON t.account_id = a.id
WHERE a.space_id = $1;`
err := r.db.Get(&count, query, spaceID)
return count, err
}

View file

@ -239,6 +239,10 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("GET /app/spaces/{spaceID}/components/report-charts", reportChartsWithAuth)
// Component routes (HTMX updates)
transferHistoryHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetTransferHistory)
transferHistoryWithAuth := middleware.RequireAuth(transferHistoryHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/components/transfer-history", transferHistoryWithAuth)
balanceCardHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetBalanceCard)
balanceCardWithAuth := middleware.RequireAuth(balanceCardHandler)
mux.HandleFunc("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAuth)

View file

@ -171,3 +171,31 @@ func (s *MoneyAccountService) GetAccountBalance(accountID string) (int, error) {
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (int, error) {
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
}
const TransfersPerPage = 25
func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page int) ([]*model.AccountTransferWithAccount, int, error) {
total, err := s.accountRepo.CountTransfersBySpaceID(spaceID)
if err != nil {
return nil, 0, err
}
totalPages := (total + TransfersPerPage - 1) / TransfersPerPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * TransfersPerPage
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
if err != nil {
return nil, 0, err
}
return transfers, totalPages, nil
}

View file

@ -2,6 +2,7 @@ package moneyaccount
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
@ -10,6 +11,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
)
@ -538,6 +540,125 @@ templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithB
</form>
}
templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
<div class="space-y-4 mt-8">
<h2 class="text-xl font-bold">Transfer History</h2>
<div class="border rounded-lg">
<div id="transfer-history-wrapper">
@TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false)
</div>
</div>
</div>
}
templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) {
<div
if oob {
id="transfer-history-wrapper"
hx-swap-oob="innerHTML:#transfer-history-wrapper"
}
>
<div class="divide-y">
if len(transfers) == 0 {
<p class="p-4 text-sm text-muted-foreground">No transfers recorded yet.</p>
}
for _, t := range transfers {
@TransferHistoryItem(spaceID, t)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage-1),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, pg),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage+1),
"hx-target": "#transfer-history-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
</div>
}
templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
<div id={ "transfer-" + t.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium">
if t.Note != "" {
{ t.Note }
} else if t.Direction == model.TransferDirectionDeposit {
Deposit
} else {
Withdrawal
}
</p>
if t.RecurringDepositID != nil {
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
}
</div>
<p class="text-sm text-muted-foreground">
{ t.CreatedAt.Format("Jan 2, 2006") } &middot; { t.AccountName }
</p>
</div>
<div class="flex items-center gap-2">
if t.Direction == model.TransferDirectionDeposit {
<span class="font-bold text-green-600 whitespace-nowrap">
+{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
</span>
} else {
<span class="font-bold text-destructive whitespace-nowrap">
-{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
</span>
}
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Class: "size-7",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers/%s", spaceID, t.AccountID, t.ID),
"hx-target": "#transfer-" + t.ID,
"hx-swap": "delete",
"hx-confirm": "Delete this transfer?",
},
}) {
@icon.Trash2(icon.Props{Size: 14})
}
</div>
</div>
}
templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) {
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID) }

View file

@ -8,7 +8,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, recurringDeposits []*model.RecurringDepositWithAccount) {
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, recurringDeposits []*model.RecurringDepositWithAccount, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
@layouts.Space("Accounts", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
@ -42,6 +42,7 @@ templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBal
}
</div>
@moneyaccount.RecurringDepositsSection(space.ID, recurringDeposits, accounts)
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
</div>
}
}