feat: transfer funds between accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m32s

This commit is contained in:
juancwu 2026-05-04 02:18:30 +00:00
commit ff237e2fab
14 changed files with 1186 additions and 60 deletions

View file

@ -329,12 +329,13 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
}
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
AccountBalance: account.Balance,
RecentTransactions: recent,
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
AccountBalance: account.Balance,
RecentTransactions: recent,
NonEditableTransactionIDs: h.nonEditableTransactionIDs(recent),
}))
}
@ -392,18 +393,40 @@ func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *ht
}
ui.Render(w, r, pages.SpaceAccountTransactionsPage(pages.SpaceAccountTransactionsPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
Transactions: txns,
CurrentPage: page,
TotalPages: totalPages,
TotalCount: total,
PerPage: perPage,
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
Transactions: txns,
NonEditableTransactionIDs: h.nonEditableTransactionIDs(txns),
CurrentPage: page,
TotalPages: totalPages,
TotalCount: total,
PerPage: perPage,
}))
}
// nonEditableTransactionIDs returns the subset of the given transactions that
// are part of a transfer pair and therefore not editable. Returns an empty
// (non-nil) map on error so list rendering still works — failure here just
// means stale Edit buttons appear; the service layer will still refuse the
// edit, so it's a UX degradation rather than a correctness issue.
func (h *spaceHandler) nonEditableTransactionIDs(txns []*model.Transaction) map[string]bool {
if len(txns) == 0 {
return nil
}
ids := make([]string, len(txns))
for i, t := range txns {
ids[i] = t.ID
}
hits, err := h.transactionService.TransferIDsIn(ids)
if err != nil {
slog.Error("failed to look up transfer ids", "error", err)
return map[string]bool{}
}
return hits
}
func (h *spaceHandler) SpaceSettingsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
@ -1042,6 +1065,26 @@ func (h *spaceHandler) SpaceTransactionPage(w http.ResponseWriter, r *http.Reque
}
}
relatedID, err := h.transactionService.GetRelatedTransactionID(transactionID)
if err != nil {
slog.Error("failed to load related transaction", "error", err, "transaction_id", transactionID)
relatedID = ""
}
var relatedTxn *model.Transaction
var relatedAccount *model.Account
if relatedID != "" {
relatedTxn, err = h.transactionService.GetTransaction(relatedID)
if err != nil {
slog.Error("failed to load related transaction details", "error", err, "related_id", relatedID)
} else {
relatedAccount, err = h.accountService.GetAccount(relatedTxn.AccountID)
if err != nil {
slog.Error("failed to load related transaction account", "error", err, "account_id", relatedTxn.AccountID)
relatedAccount = nil
}
}
}
recentLogs, err := h.txAuditLogService.List(transactionID, 5, 0)
if err != nil {
slog.Error("failed to load transaction audit logs", "error", err, "transaction_id", transactionID)
@ -1054,14 +1097,16 @@ func (h *spaceHandler) SpaceTransactionPage(w http.ResponseWriter, r *http.Reque
}
ui.Render(w, r, pages.SpaceTransactionPage(pages.SpaceTransactionPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
Transaction: txn,
CategoryName: categoryName,
RecentAuditLogs: recentLogs,
AuditLogCount: logCount,
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
Transaction: txn,
CategoryName: categoryName,
RecentAuditLogs: recentLogs,
AuditLogCount: logCount,
RelatedTransaction: relatedTxn,
RelatedAccount: relatedAccount,
}))
}
@ -1211,6 +1256,22 @@ func (h *spaceHandler) SpaceEditTransactionPage(w http.ResponseWriter, r *http.R
return
}
// Transfers must be edited as a pair; refuse the edit page entirely.
relatedID, err := h.transactionService.GetRelatedTransactionID(transactionID)
if err != nil {
slog.Error("failed to check transfer linkage", "error", err, "transaction_id", transactionID)
}
if relatedID != "" {
redirectTo := routeurl.URL(
"page.app.spaces.space.accounts.account.transactions.transaction",
"spaceID", spaceID,
"accountID", accountID,
"transactionID", transactionID,
)
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
return
}
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to load space", "error", err, "space_id", spaceID)
@ -1286,6 +1347,15 @@ func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Requ
return
}
// Defense in depth — the edit page redirects away for transfers, but a
// hand-crafted POST shouldn't be able to bypass it.
if relatedID, err := h.transactionService.GetRelatedTransactionID(transactionID); err != nil {
slog.Error("failed to check transfer linkage", "error", err, "transaction_id", transactionID)
} else if relatedID != "" {
ui.RenderError(w, r, "Transfer transactions cannot be edited.", http.StatusBadRequest)
return
}
titleInput := strings.TrimSpace(r.FormValue("title"))
amountInput := strings.TrimSpace(r.FormValue("amount"))
dateInput := strings.TrimSpace(r.FormValue("date"))
@ -1431,6 +1501,183 @@ func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Requ
w.WriteHeader(http.StatusOK)
}
func (h *spaceHandler) SpaceCreateTransferPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
account, err := h.accountService.GetAccount(accountID)
if err != nil || account.SpaceID != spaceID {
ui.Render(w, r, pages.NotFound())
return
}
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to load space", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError)
return
}
dests, err := h.transferDestinations(spaceID, accountID)
if err != nil {
slog.Error("failed to load destination accounts", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceCreateTransferPage(pages.SpaceCreateTransferPageProps{
SpaceID: spaceID,
SpaceName: space.Name,
AccountID: accountID,
AccountName: account.Name,
Form: forms.CreateTransferProps{
SpaceID: spaceID,
SourceAccountID: accountID,
DestAccounts: dests,
Date: time.Now().Format("2006-01-02"),
},
}))
}
func (h *spaceHandler) HandleCreateTransfer(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
source, err := h.accountService.GetAccount(accountID)
if err != nil || source.SpaceID != spaceID {
ui.RenderError(w, r, "Account not found", http.StatusNotFound)
return
}
dests, err := h.transferDestinations(spaceID, accountID)
if err != nil {
slog.Error("failed to load destination accounts", "error", err, "space_id", spaceID)
ui.RenderError(w, r, "Failed to load form", http.StatusInternalServerError)
return
}
titleInput := strings.TrimSpace(r.FormValue("title"))
amountInput := strings.TrimSpace(r.FormValue("amount"))
destInput := strings.TrimSpace(r.FormValue("destination"))
dateInput := strings.TrimSpace(r.FormValue("date"))
descriptionInput := strings.TrimSpace(r.FormValue("description"))
formProps := forms.CreateTransferProps{
SpaceID: spaceID,
SourceAccountID: accountID,
DestAccounts: dests,
Title: titleInput,
Amount: amountInput,
DestAccountID: destInput,
Date: dateInput,
Description: descriptionInput,
}
hasErr := false
if titleInput == "" {
formProps.TitleErr = "Title is required."
hasErr = true
}
var amount decimal.Decimal
if amountInput == "" {
formProps.AmountErr = "Amount is required."
hasErr = true
} else {
amt, err := decimal.NewFromString(amountInput)
if err != nil {
formProps.AmountErr = "Enter a valid amount (e.g. 12.34)."
hasErr = true
} else if !amt.IsPositive() {
formProps.AmountErr = "Amount must be greater than zero."
hasErr = true
} else if amt.Exponent() < -2 {
formProps.AmountErr = "Amount can have at most 2 decimal places."
hasErr = true
} else {
amount = amt
}
}
if destInput == "" {
formProps.DestErr = "Choose a destination account."
hasErr = true
} else if destInput == accountID {
formProps.DestErr = "Destination must be a different account."
hasErr = true
} else {
// Verify the destination is in the same space (defends against hand-crafted requests).
destAcct, err := h.accountService.GetAccount(destInput)
if err != nil || destAcct.SpaceID != spaceID {
formProps.DestErr = "Destination account not found."
hasErr = true
}
}
var occurredAt time.Time
if dateInput == "" {
formProps.DateErr = "Date is required."
hasErr = true
} else {
parsed, err := time.Parse("2006-01-02", dateInput)
if err != nil {
formProps.DateErr = "Enter a valid date."
hasErr = true
} else {
occurredAt = parsed
}
}
if hasErr {
ui.Render(w, r, forms.CreateTransfer(formProps))
return
}
actorID := ""
if u := ctxkeys.User(r.Context()); u != nil {
actorID = u.ID
}
if _, err := h.transactionService.Transfer(service.TransferInput{
SourceAccountID: accountID,
DestAccountID: destInput,
Title: titleInput,
Amount: amount,
OccurredAt: occurredAt,
Description: descriptionInput,
ActorID: actorID,
}); err != nil {
slog.Error("failed to create transfer", "error", err, "source", accountID, "dest", destInput)
formProps.GeneralErr = "Something went wrong. Please try again."
ui.Render(w, r, forms.CreateTransfer(formProps))
return
}
redirectTo := routeurl.URL(
"page.app.spaces.space.accounts.account.overview",
"spaceID", spaceID,
"accountID", accountID,
)
w.Header().Set("HX-Redirect", redirectTo)
w.WriteHeader(http.StatusOK)
}
// transferDestinations returns every account in the space except the source.
func (h *spaceHandler) transferDestinations(spaceID, sourceAccountID string) ([]*model.Account, error) {
all, err := h.accountService.GetAccountsForSpace(spaceID)
if err != nil {
return nil, err
}
out := make([]*model.Account, 0, len(all))
for _, a := range all {
if a.ID == sourceAccountID {
continue
}
out = append(out, a)
}
return out, nil
}
func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")

View file

@ -14,8 +14,11 @@ type TransactionRepository interface {
CreateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error
UpdateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error
UpdateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error
TransferAtomic(withdrawal, deposit *model.Transaction, sourceNewBalance, destNewBalance decimal.Decimal) error
GetByID(id string) (*model.Transaction, error)
GetCategoryID(transactionID string) (*string, error)
GetRelatedID(transactionID string) (*string, error)
TransferIDsIn(ids []string) (map[string]bool, error)
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
CountByAccount(accountID string) (int, error)
}
@ -138,6 +141,55 @@ func (r *transactionRepository) UpdateDepositAtomic(t *model.Transaction, newBal
})
}
// TransferAtomic creates the withdrawal + deposit transaction pair, updates both
// account balances, and links the two via related_transactions in a single SQL
// transaction. Negative balances are allowed — overdraft enforcement is a product
// decision left to the service layer.
func (r *transactionRepository) TransferAtomic(withdrawal, deposit *model.Transaction, sourceNewBalance, destNewBalance decimal.Decimal) error {
return WithTx(r.db, func(tx *sqlx.Tx) error {
insertTxn := `
INSERT INTO transactions
(id, value, type, account_id, title, description, occurred_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
`
if _, err := tx.Exec(insertTxn,
withdrawal.ID, withdrawal.Value, withdrawal.Type, withdrawal.AccountID, withdrawal.Title,
withdrawal.Description, withdrawal.OccurredAt, withdrawal.CreatedAt, withdrawal.UpdatedAt,
); err != nil {
return err
}
if _, err := tx.Exec(insertTxn,
deposit.ID, deposit.Value, deposit.Type, deposit.AccountID, deposit.Title,
deposit.Description, deposit.OccurredAt, deposit.CreatedAt, deposit.UpdatedAt,
); err != nil {
return err
}
updateBalance := `UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3;`
now := time.Now()
if _, err := tx.Exec(updateBalance, sourceNewBalance, now, withdrawal.AccountID); err != nil {
return err
}
if _, err := tx.Exec(updateBalance, destNewBalance, now, deposit.AccountID); err != nil {
return err
}
// related_transactions has CHECK (transaction_one_id < transaction_two_id);
// order the IDs to satisfy it.
one, two := withdrawal.ID, deposit.ID
if one > two {
one, two = two, one
}
if _, err := tx.Exec(
`INSERT INTO related_transactions (transaction_one_id, transaction_two_id) VALUES ($1, $2);`,
one, two,
); err != nil {
return err
}
return nil
})
}
func (r *transactionRepository) GetByID(id string) (*model.Transaction, error) {
query := `
SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at
@ -151,6 +203,55 @@ func (r *transactionRepository) GetByID(id string) (*model.Transaction, error) {
return t, nil
}
// GetRelatedID returns the other half of a transfer pair if `transactionID` is
// part of one. Returns (nil, nil) when the transaction is standalone.
func (r *transactionRepository) GetRelatedID(transactionID string) (*string, error) {
var other string
err := r.db.Get(&other, `
SELECT CASE
WHEN transaction_one_id = $1 THEN transaction_two_id
ELSE transaction_one_id
END
FROM related_transactions
WHERE transaction_one_id = $1 OR transaction_two_id = $1
LIMIT 1;
`, transactionID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &other, nil
}
// TransferIDsIn returns the subset of `ids` that appear in related_transactions
// (either side). Used by list pages to decide which rows are non-editable so we
// don't N+1 a per-row check.
func (r *transactionRepository) TransferIDsIn(ids []string) (map[string]bool, error) {
if len(ids) == 0 {
return map[string]bool{}, nil
}
query, args, err := sqlx.In(`
SELECT transaction_one_id AS id FROM related_transactions WHERE transaction_one_id IN (?)
UNION
SELECT transaction_two_id AS id FROM related_transactions WHERE transaction_two_id IN (?)
`, ids, ids)
if err != nil {
return nil, err
}
query = r.db.Rebind(query)
var hits []string
if err := r.db.Select(&hits, query, args...); err != nil {
return nil, err
}
out := make(map[string]bool, len(hits))
for _, id := range hits {
out[id] = true
}
return out, nil
}
func (r *transactionRepository) GetCategoryID(transactionID string) (*string, error) {
var id string
err := r.db.Get(&id, `SELECT category_id FROM transaction_categories WHERE transaction_id = $1 LIMIT 1;`, transactionID)

View file

@ -0,0 +1,113 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTransactionRepository_TransferAtomic_LinksPairAndUpdatesBalances(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTransactionRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "transfer-repo@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
src := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Src")
dst := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Dst")
now := time.Now()
withdrawal := &model.Transaction{
ID: uuid.NewString(), Value: decimal.NewFromInt(40), Type: model.TransactionTypeWithdrawal,
AccountID: src.ID, Title: "Move", OccurredAt: now, CreatedAt: now, UpdatedAt: now,
}
deposit := &model.Transaction{
ID: uuid.NewString(), Value: decimal.NewFromInt(40), Type: model.TransactionTypeDeposit,
AccountID: dst.ID, Title: "Move", OccurredAt: now, CreatedAt: now, UpdatedAt: now,
}
err := repo.TransferAtomic(withdrawal, deposit, decimal.NewFromInt(-40), decimal.NewFromInt(40))
require.NoError(t, err)
// Both transactions exist.
w, err := repo.GetByID(withdrawal.ID)
require.NoError(t, err)
assert.Equal(t, model.TransactionTypeWithdrawal, w.Type)
d, err := repo.GetByID(deposit.ID)
require.NoError(t, err)
assert.Equal(t, model.TransactionTypeDeposit, d.Type)
// Balances were applied.
accountRepo := NewAccountRepository(dbi.DB)
srcAfter, err := accountRepo.ByID(src.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(-40).Equal(srcAfter.Balance))
dstAfter, err := accountRepo.ByID(dst.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(40).Equal(dstAfter.Balance))
// Linked both ways via related_transactions.
other, err := repo.GetRelatedID(withdrawal.ID)
require.NoError(t, err)
require.NotNil(t, other)
assert.Equal(t, deposit.ID, *other)
other, err = repo.GetRelatedID(deposit.ID)
require.NoError(t, err)
require.NotNil(t, other)
assert.Equal(t, withdrawal.ID, *other)
})
}
func TestTransactionRepository_TransferIDsIn_ReturnsOnlyTransferHalves(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTransactionRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "transferids@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
src := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Src")
dst := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Dst")
// One transfer pair (source `w`, deposit `d`) plus one standalone txn.
now := time.Now()
w := &model.Transaction{ID: uuid.NewString(), Value: decimal.NewFromInt(5), Type: model.TransactionTypeWithdrawal, AccountID: src.ID, Title: "T-w", OccurredAt: now, CreatedAt: now, UpdatedAt: now}
d := &model.Transaction{ID: uuid.NewString(), Value: decimal.NewFromInt(5), Type: model.TransactionTypeDeposit, AccountID: dst.ID, Title: "T-d", OccurredAt: now, CreatedAt: now, UpdatedAt: now}
require.NoError(t, repo.TransferAtomic(w, d, decimal.NewFromInt(-5), decimal.NewFromInt(5)))
standalone := testutil.CreateTestTransaction(t, dbi.DB, src.ID, "solo", model.TransactionTypeDeposit, decimal.NewFromInt(1))
hits, err := repo.TransferIDsIn([]string{w.ID, d.ID, standalone.ID})
require.NoError(t, err)
assert.True(t, hits[w.ID], "withdrawal half should be flagged")
assert.True(t, hits[d.ID], "deposit half should be flagged")
assert.False(t, hits[standalone.ID], "standalone transaction should not be flagged")
})
}
func TestTransactionRepository_TransferIDsIn_EmptyInput(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTransactionRepository(dbi.DB)
hits, err := repo.TransferIDsIn(nil)
require.NoError(t, err)
assert.Empty(t, hits)
})
}
func TestTransactionRepository_GetRelatedID_NoneWhenStandalone(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTransactionRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "standalone@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
acct := testutil.CreateTestAccount(t, dbi.DB, space.ID, "A")
txn := testutil.CreateTestTransaction(t, dbi.DB, acct.ID, "x", model.TransactionTypeDeposit, decimal.NewFromInt(1))
other, err := repo.GetRelatedID(txn.ID)
require.NoError(t, err)
assert.Nil(t, other)
})
}

View file

@ -121,6 +121,8 @@ func SetupRoutes(a *app.App) http.Handler {
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create")
g.Post("/deposits/create", spaceH.HandleCreateDeposit).Name("action.app.spaces.space.accounts.account.deposits.create")
g.Get("/transfers/create", spaceH.SpaceCreateTransferPage).Name("page.app.spaces.space.accounts.account.transfers.create")
g.Post("/transfers/create", spaceH.HandleCreateTransfer).Name("action.app.spaces.space.accounts.account.transfers.create")
})
})
})

View file

@ -1,6 +1,7 @@
package service
import (
"errors"
"fmt"
"strings"
"time"
@ -11,6 +12,13 @@ import (
"github.com/shopspring/decimal"
)
// ErrTransactionPartOfTransfer is returned when an operation that mutates a
// single transaction (edit, delete) is attempted on one half of a transfer.
// Transfers must be edited as a pair or not at all to keep both sides in sync;
// callers should surface a user-facing message and offer to undo the transfer
// instead.
var ErrTransactionPartOfTransfer = errors.New("transaction is part of a transfer")
type TransactionService struct {
transactionRepo repository.TransactionRepository
categoryRepo repository.CategoryRepository
@ -176,6 +184,153 @@ func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, er
return txn, nil
}
type TransferInput struct {
SourceAccountID string
DestAccountID string
Title string
Amount decimal.Decimal
OccurredAt time.Time
Description string
ActorID string
}
// TransferResult is what the service returns after a successful transfer — both
// halves are surfaced so callers can audit, redirect, or render either side.
type TransferResult struct {
Withdrawal *model.Transaction
Deposit *model.Transaction
}
// Transfer moves funds from one account to another. It creates two linked
// transactions (a withdrawal on the source, a deposit on the destination) plus
// a row in related_transactions, all in a single SQL transaction.
//
// Negative balances are intentionally allowed — the product permits overdraft.
// Source must differ from destination; the amount must be positive (the sign is
// implicit in the transaction type).
func (s *TransactionService) Transfer(input TransferInput) (*TransferResult, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("title is required")
}
if input.SourceAccountID == "" || input.DestAccountID == "" {
return nil, fmt.Errorf("source and destination account ids are required")
}
if input.SourceAccountID == input.DestAccountID {
return nil, fmt.Errorf("source and destination must differ")
}
if !input.Amount.IsPositive() {
return nil, fmt.Errorf("amount must be greater than zero")
}
if input.OccurredAt.IsZero() {
return nil, fmt.Errorf("date is required")
}
source, err := s.accountService.GetAccount(input.SourceAccountID)
if err != nil {
return nil, fmt.Errorf("failed to load source account: %w", err)
}
dest, err := s.accountService.GetAccount(input.DestAccountID)
if err != nil {
return nil, fmt.Errorf("failed to load destination account: %w", err)
}
now := time.Now()
var description *string
if d := strings.TrimSpace(input.Description); d != "" {
description = &d
}
withdrawal := &model.Transaction{
ID: uuid.NewString(),
Value: input.Amount,
Type: model.TransactionTypeWithdrawal,
AccountID: source.ID,
Title: title,
Description: description,
OccurredAt: input.OccurredAt,
CreatedAt: now,
UpdatedAt: now,
}
deposit := &model.Transaction{
ID: uuid.NewString(),
Value: input.Amount,
Type: model.TransactionTypeDeposit,
AccountID: dest.ID,
Title: title,
Description: description,
OccurredAt: input.OccurredAt,
CreatedAt: now,
UpdatedAt: now,
}
sourceNewBalance := source.Balance.Sub(input.Amount)
destNewBalance := dest.Balance.Add(input.Amount)
if err := s.transactionRepo.TransferAtomic(withdrawal, deposit, sourceNewBalance, destNewBalance); err != nil {
return nil, fmt.Errorf("failed to record transfer: %w", err)
}
// Audit each side. Metadata captures the role and the other half so the
// activity feed can render "Transferred to/from <other account>" without a
// follow-up query.
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: withdrawal.ID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionCreated,
Metadata: map[string]any{
"account_id": withdrawal.AccountID,
"transaction_type": string(withdrawal.Type),
"title": withdrawal.Title,
"amount": withdrawal.Value.StringFixedBank(2),
"transfer_role": "source",
"transfer_pair_id": deposit.ID,
"transfer_other_acct": deposit.AccountID,
"transfer_other_name": dest.Name,
},
})
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: deposit.ID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionCreated,
Metadata: map[string]any{
"account_id": deposit.AccountID,
"transaction_type": string(deposit.Type),
"title": deposit.Title,
"amount": deposit.Value.StringFixedBank(2),
"transfer_role": "destination",
"transfer_pair_id": withdrawal.ID,
"transfer_other_acct": withdrawal.AccountID,
"transfer_other_name": source.Name,
},
})
return &TransferResult{Withdrawal: withdrawal, Deposit: deposit}, nil
}
// TransferIDsIn returns the subset of the given transaction IDs that are part
// of a transfer pair. Empty input yields an empty (non-nil) map.
func (s *TransactionService) TransferIDsIn(ids []string) (map[string]bool, error) {
hits, err := s.transactionRepo.TransferIDsIn(ids)
if err != nil {
return nil, fmt.Errorf("failed to look up transfer ids: %w", err)
}
return hits, nil
}
// GetRelatedTransactionID returns the other half of a transfer pair, or "" if
// the transaction is not part of a transfer.
func (s *TransactionService) GetRelatedTransactionID(transactionID string) (string, error) {
id, err := s.transactionRepo.GetRelatedID(transactionID)
if err != nil {
return "", fmt.Errorf("failed to load related transaction: %w", err)
}
if id == nil {
return "", nil
}
return *id, nil
}
type UpdateBillInput struct {
TransactionID string
Title string
@ -208,6 +363,11 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti
if existing.Type != model.TransactionTypeWithdrawal {
return nil, fmt.Errorf("transaction is not a bill")
}
if related, err := s.transactionRepo.GetRelatedID(existing.ID); err != nil {
return nil, fmt.Errorf("failed to check transfer linkage: %w", err)
} else if related != nil {
return nil, ErrTransactionPartOfTransfer
}
account, err := s.accountService.GetAccount(existing.AccountID)
if err != nil {
@ -289,6 +449,11 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
if existing.Type != model.TransactionTypeDeposit {
return nil, fmt.Errorf("transaction is not a deposit")
}
if related, err := s.transactionRepo.GetRelatedID(existing.ID); err != nil {
return nil, fmt.Errorf("failed to check transfer linkage: %w", err)
} else if related != nil {
return nil, ErrTransactionPartOfTransfer
}
account, err := s.accountService.GetAccount(existing.AccountID)
if err != nil {

View file

@ -246,6 +246,241 @@ func TestTransactionService_UpdateDeposit_RejectsBillTransaction(t *testing.T) {
})
}
func TestTransactionService_Transfer_HappyPath(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dest := testutil.CreateTestAccount(t, dbi.DB, f.account.SpaceID, "Savings")
result, err := f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID,
DestAccountID: dest.ID,
Title: "Move to savings",
Amount: decimal.NewFromInt(50),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.NoError(t, err)
require.NotNil(t, result.Withdrawal)
require.NotNil(t, result.Deposit)
assert.Equal(t, model.TransactionTypeWithdrawal, result.Withdrawal.Type)
assert.Equal(t, model.TransactionTypeDeposit, result.Deposit.Type)
assert.Equal(t, f.account.ID, result.Withdrawal.AccountID)
assert.Equal(t, dest.ID, result.Deposit.AccountID)
// Source went from 0 → -50 (overdraft allowed); dest 0 → +50.
src, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(-50).Equal(src.Balance))
dst, err := f.accounts.ByID(dest.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(50).Equal(dst.Balance))
// Transactions are linked.
relatedID, err := f.svc.GetRelatedTransactionID(result.Withdrawal.ID)
require.NoError(t, err)
assert.Equal(t, result.Deposit.ID, relatedID)
// Audit recorded both sides with the right transfer_role and other-account name.
wlogs, err := f.txAudit.ListByTransaction(result.Withdrawal.ID, 10, 0)
require.NoError(t, err)
require.Len(t, wlogs, 1)
var wmeta map[string]any
require.NoError(t, json.Unmarshal(wlogs[0].Metadata, &wmeta))
assert.Equal(t, "source", wmeta["transfer_role"])
assert.Equal(t, result.Deposit.ID, wmeta["transfer_pair_id"])
assert.Equal(t, "Savings", wmeta["transfer_other_name"])
dlogs, err := f.txAudit.ListByTransaction(result.Deposit.ID, 10, 0)
require.NoError(t, err)
require.Len(t, dlogs, 1)
var dmeta map[string]any
require.NoError(t, json.Unmarshal(dlogs[0].Metadata, &dmeta))
assert.Equal(t, "destination", dmeta["transfer_role"])
assert.Equal(t, result.Withdrawal.ID, dmeta["transfer_pair_id"])
assert.Equal(t, "Acct", dmeta["transfer_other_name"])
})
}
func TestTransactionService_Transfer_AllowsOverdraft(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dest := testutil.CreateTestAccount(t, dbi.DB, f.account.SpaceID, "B")
// Seed source to 100, then transfer 200 → -100.
_, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "seed", Amount: decimal.NewFromInt(100),
OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
_, err = f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID, DestAccountID: dest.ID,
Title: "T1", Amount: decimal.NewFromInt(200), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
src, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(-100).Equal(src.Balance), "expected -100, got %s", src.Balance.String())
// Transfer another 200 from -100 → -300.
_, err = f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID, DestAccountID: dest.ID,
Title: "T2", Amount: decimal.NewFromInt(200), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
src, err = f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(-300).Equal(src.Balance), "expected -300, got %s", src.Balance.String())
})
}
// TestTransactionService_Transfer_AppearsInAccountActivityFeeds is the regression
// test for "make sure activity logs are also created". The activity views on each
// account page and the space-level page merge transaction_audit_logs into a unified
// feed; this test exercises the full path end-to-end so silent gaps in audit
// recording or merging would fail.
func TestTransactionService_Transfer_AppearsInActivityFeeds(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dest := testutil.CreateTestAccount(t, dbi.DB, f.account.SpaceID, "Savings")
spaceAuditRepo := repository.NewSpaceAuditLogRepository(dbi.DB)
activitySvc := NewAccountActivityService(
NewSpaceAuditLogService(spaceAuditRepo),
NewTransactionAuditLogService(f.txAudit),
)
result, err := f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID,
DestAccountID: dest.ID,
Title: "Move to savings",
Amount: decimal.NewFromInt(75),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.NoError(t, err)
// Source account activity sees the withdrawal half.
srcRows, err := activitySvc.List(f.account.ID, 10, 0)
require.NoError(t, err)
require.Len(t, srcRows, 1, "source account feed should include the withdrawal half")
require.NotNil(t, srcRows[0].TxLog)
assert.Equal(t, result.Withdrawal.ID, srcRows[0].TxLog.TransactionID)
assertTransferRole(t, srcRows[0].TxLog, "source", dest.ID)
// Destination account activity sees the deposit half.
dstRows, err := activitySvc.List(dest.ID, 10, 0)
require.NoError(t, err)
require.Len(t, dstRows, 1, "destination account feed should include the deposit half")
require.NotNil(t, dstRows[0].TxLog)
assert.Equal(t, result.Deposit.ID, dstRows[0].TxLog.TransactionID)
assertTransferRole(t, dstRows[0].TxLog, "destination", f.account.ID)
// Space-level activity feed sees both halves.
spaceRows, err := activitySvc.ListSpace(f.account.SpaceID, 10, 0)
require.NoError(t, err)
require.Len(t, spaceRows, 2, "space feed should include both halves of the transfer")
ids := []string{}
for _, r := range spaceRows {
require.NotNil(t, r.TxLog)
ids = append(ids, r.TxLog.TransactionID)
}
assert.Contains(t, ids, result.Withdrawal.ID)
assert.Contains(t, ids, result.Deposit.ID)
// Counts agree with what the feed returns (pagination relies on this).
srcCount, err := activitySvc.Count(f.account.ID)
require.NoError(t, err)
assert.Equal(t, 1, srcCount)
dstCount, err := activitySvc.Count(dest.ID)
require.NoError(t, err)
assert.Equal(t, 1, dstCount)
spaceCount, err := activitySvc.CountSpace(f.account.SpaceID)
require.NoError(t, err)
// Source account had no activity before the transfer, dest is brand-new;
// the only activity in the space is the two transfer halves.
assert.Equal(t, 2, spaceCount)
})
}
func assertTransferRole(t *testing.T, log *model.TransactionAuditLogWithActor, expectedRole, expectedOtherAcctID string) {
t.Helper()
var meta map[string]any
require.NoError(t, json.Unmarshal(log.Metadata, &meta))
assert.Equal(t, expectedRole, meta["transfer_role"])
assert.Equal(t, expectedOtherAcctID, meta["transfer_other_acct"])
}
func TestTransactionService_Transfer_RejectsSameAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
_, err := f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID,
DestAccountID: f.account.ID,
Title: "Self",
Amount: decimal.NewFromInt(10),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
assert.Error(t, err)
})
}
func TestTransactionService_Transfer_RejectsNonPositiveAmount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dest := testutil.CreateTestAccount(t, dbi.DB, f.account.SpaceID, "B")
_, err := f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID, DestAccountID: dest.ID,
Title: "x", Amount: decimal.NewFromInt(0), OccurredAt: time.Now(), ActorID: f.user.ID,
})
assert.Error(t, err)
})
}
func TestTransactionService_Update_RejectsTransferTransactions(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dest := testutil.CreateTestAccount(t, dbi.DB, f.account.SpaceID, "Savings")
result, err := f.svc.Transfer(TransferInput{
SourceAccountID: f.account.ID,
DestAccountID: dest.ID,
Title: "Initial",
Amount: decimal.NewFromInt(20),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.NoError(t, err)
// The withdrawal half cannot be edited via UpdateBill.
_, err = f.svc.UpdateBill(UpdateBillInput{
TransactionID: result.Withdrawal.ID,
Title: "tampered",
Amount: decimal.NewFromInt(99),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.ErrorIs(t, err, ErrTransactionPartOfTransfer)
// The deposit half cannot be edited via UpdateDeposit.
_, err = f.svc.UpdateDeposit(UpdateDepositInput{
TransactionID: result.Deposit.ID,
Title: "tampered",
Amount: decimal.NewFromInt(99),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.ErrorIs(t, err, ErrTransactionPartOfTransfer)
// Underlying transaction is untouched (no audit `edited` row added either).
count, err := f.txAudit.CountByTransaction(result.Withdrawal.ID)
require.NoError(t, err)
assert.Equal(t, 1, count, "only the original `created` audit row should exist")
})
}
func TestTransactionService_Validations(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)

View file

@ -10,6 +10,9 @@ type TransactionListProps struct {
SpaceID string
AccountID string
Transactions []*model.Transaction
// NonEditableIDs marks transaction IDs whose Edit button should be hidden
// (currently: transfer halves). Nil/empty means everything is editable.
NonEditableIDs map[string]bool
}
templ TransactionList(props TransactionListProps) {
@ -20,13 +23,13 @@ templ TransactionList(props TransactionListProps) {
} else {
<ul class="divide-y">
for _, t := range props.Transactions {
@transactionRow(props.SpaceID, props.AccountID, t)
@transactionRow(props.SpaceID, props.AccountID, t, !props.NonEditableIDs[t.ID])
}
</ul>
}
}
templ transactionRow(spaceID, accountID string, t *model.Transaction) {
templ transactionRow(spaceID, accountID string, t *model.Transaction, editable bool) {
{{
isDeposit := t.Type == model.TransactionTypeDeposit
amountClasses := []string{"text-sm font-semibold tabular-nums"}
@ -70,7 +73,7 @@ templ transactionRow(spaceID, accountID string, t *model.Transaction) {
<p class="text-xs text-muted-foreground truncate max-w-[200px]">{ *t.Description }</p>
}
</div>
if spaceID != "" && accountID != "" {
if spaceID != "" && accountID != "" && editable {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,

View file

@ -0,0 +1,176 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea"
type CreateTransferProps struct {
SpaceID string
SourceAccountID string
// DestAccounts is the list of other accounts in the same space the user
// can transfer to. Excludes the source account.
DestAccounts []*model.Account
Title string
Amount string
DestAccountID string
Date string
Description string
TitleErr string
AmountErr string
DestErr string
DateErr string
GeneralErr string
}
templ CreateTransfer(props CreateTransferProps) {
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.transfers.create", "spaceID", props.SpaceID, "accountID", props.SourceAccountID) }>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4 space-y-4"}) {
if props.GeneralErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.GeneralErr }
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "title"}) {
Title
}
@input.Input(input.Props{
ID: "title",
Name: "title",
Type: input.TypeText,
Placeholder: "e.g. Move to savings",
Class: "rounded-sm",
Value: props.Title,
HasError: props.TitleErr != "",
Required: true,
Attributes: templ.Attributes{
"autocomplete": "off",
"autofocus": "",
},
})
if props.TitleErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.TitleErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "destination"}) {
Destination account
}
if len(props.DestAccounts) == 0 {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
This space has no other accounts. Create one first.
}
} else {
<select
id="destination"
name="destination"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.DestErr != ""),
templ.KV("border-input", props.DestErr == "") }
required
>
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
for _, a := range props.DestAccounts {
<option value={ a.ID } selected?={ props.DestAccountID == a.ID }>{ a.Name }</option>
}
</select>
}
if props.DestErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.DestErr }
}
}
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "amount"}) {
Amount
}
@input.Input(input.Props{
ID: "amount",
Name: "amount",
Type: input.TypeNumber,
Placeholder: "0.00",
Class: "rounded-sm",
Value: props.Amount,
HasError: props.AmountErr != "",
Required: true,
Attributes: templ.Attributes{
"step": "0.01",
"min": "0",
"inputmode": "decimal",
"autocomplete": "off",
},
})
if props.AmountErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.AmountErr }
}
}
@form.Description() {
Transfers may exceed the source balance and result in a negative balance.
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "date"}) {
Date
}
@input.Input(input.Props{
ID: "date",
Name: "date",
Type: input.TypeDate,
Class: "rounded-sm",
Value: props.Date,
HasError: props.DateErr != "",
Required: true,
})
if props.DateErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.DateErr }
}
}
}
</div>
@form.Item() {
@form.Label(form.LabelProps{For: "description"}) {
Description
}
@textarea.Textarea(textarea.Props{
ID: "description",
Name: "description",
Placeholder: "Anything extra worth remembering",
Rows: 3,
Value: props.Description,
})
@form.Description() {
Optional. Shared by both sides of the transfer.
}
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", props.SpaceID, "accountID", props.SourceAccountID),
}) {
Cancel
}
@button.Button(button.Props{
Type: button.TypeSubmit,
Disabled: len(props.DestAccounts) == 0,
}) {
Transfer
}
}
}
</form>
}

View file

@ -16,7 +16,8 @@ type SpaceAccountPageProps struct {
AccountID string
AccountName string
AccountBalance decimal.Decimal
RecentTransactions []*model.Transaction
RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
}
templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -92,9 +93,10 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}
@card.Content() {
@blocks.TransactionList(blocks.TransactionListProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Transactions: props.RecentTransactions,
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Transactions: props.RecentTransactions,
NonEditableIDs: props.NonEditableTransactionIDs,
})
}
@card.Footer(card.FooterProps{Class: "justify-end"}) {

View file

@ -147,9 +147,12 @@ func accountActivityTxMessage(spaceID, accountID string, log *model.TransactionA
switch log.Action {
case model.TransactionAuditActionCreated:
var meta struct {
TransactionType string `json:"transaction_type"`
Title string `json:"title"`
Amount string `json:"amount"`
TransactionType string `json:"transaction_type"`
Title string `json:"title"`
Amount string `json:"amount"`
TransferRole string `json:"transfer_role"`
TransferOtherAcct string `json:"transfer_other_acct"`
TransferOtherName string `json:"transfer_other_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
title := meta.Title
@ -161,6 +164,20 @@ func accountActivityTxMessage(spaceID, accountID string, log *model.TransactionA
if meta.Amount != "" {
amountSuffix = fmt.Sprintf(" for $%s", templEscape(meta.Amount))
}
// Transfer events get a more descriptive sentence so users can see
// which side this entry is and where the money went / came from.
if meta.TransferRole != "" {
direction := "to"
if meta.TransferRole == "destination" {
direction = "from"
}
otherName := meta.TransferOtherName
if otherName == "" {
otherName = "another account"
}
return fmt.Sprintf("%s transferred %s%s %s %s.",
actor, titleHTML, amountSuffix, templEscape(direction), bold(otherName))
}
return fmt.Sprintf("%s added a %s %s%s.",
actor, templEscape(transactionTypeLabel(meta.TransactionType)), titleHTML, amountSuffix)
case model.TransactionAuditActionEdited:

View file

@ -11,15 +11,16 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
type SpaceAccountTransactionsPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Transactions []*model.Transaction
CurrentPage int
TotalPages int
TotalCount int
PerPage int
SpaceID string
SpaceName string
AccountID string
AccountName string
Transactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
templ SpaceAccountTransactionsPage(props SpaceAccountTransactionsPageProps) {
@ -62,9 +63,10 @@ templ SpaceAccountTransactionsPage(props SpaceAccountTransactionsPageProps) {
}
@card.Content() {
@blocks.TransactionList(blocks.TransactionListProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Transactions: props.Transactions,
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Transactions: props.Transactions,
NonEditableIDs: props.NonEditableTransactionIDs,
})
}
if props.TotalPages > 1 {

View file

@ -0,0 +1,32 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ui/forms"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceCreateTransferPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Form forms.CreateTransferProps
}
templ SpaceCreateTransferPage(props SpaceCreateTransferPageProps) {
@layouts.AppWithBreadcrumb(
"Transfer Funds",
accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Transfer Funds"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
spaceAccountSidebarContent(props.SpaceID, props.AccountID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
<div>
<h1 class="text-3xl font-bold">Transfer Funds</h1>
<p class="text-muted-foreground mt-2">
Move money out of { props.AccountName } into another account in this space.
</p>
</div>
@forms.CreateTransfer(props.Form)
</div>
}
}

View file

@ -144,6 +144,16 @@ templ spaceAccountSidebarContent(spaceID, accountID string) {
<span>Deposit Funds</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.accounts.account.transfers.create", "spaceID", spaceID, "accountID", accountID),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.accounts.account.transfers.create", "spaceID", spaceID, "accountID", accountID),
Tooltip: "Transfer Funds",
}) {
@icon.ArrowRightLeft()
<span>Transfer Funds</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.accounts.account.activity", "spaceID", spaceID, "accountID", accountID),

View file

@ -9,14 +9,16 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type SpaceTransactionPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Transaction *model.Transaction
CategoryName string
RecentAuditLogs []*model.TransactionAuditLogWithActor
AuditLogCount int
SpaceID string
SpaceName string
AccountID string
AccountName string
Transaction *model.Transaction
CategoryName string
RecentAuditLogs []*model.TransactionAuditLogWithActor
AuditLogCount int
RelatedTransaction *model.Transaction
RelatedAccount *model.Account
}
templ SpaceTransactionPage(props SpaceTransactionPageProps) {
@ -53,13 +55,15 @@ templ SpaceTransactionPage(props SpaceTransactionPageProps) {
{ label } in { props.AccountName }
</p>
</div>
@button.Button(button.Props{
Variant: button.VariantDefault,
Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.edit", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.Transaction.ID),
Class: "flex items-center gap-2",
}) {
@icon.Pencil(icon.Props{Class: "size-4"})
Edit
if props.RelatedTransaction == nil {
@button.Button(button.Props{
Variant: button.VariantDefault,
Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.edit", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.Transaction.ID),
Class: "flex items-center gap-2",
}) {
@icon.Pencil(icon.Props{Class: "size-4"})
Edit
}
}
</div>
@card.Card() {
@ -100,6 +104,23 @@ templ SpaceTransactionPage(props SpaceTransactionPageProps) {
<p class="whitespace-pre-wrap">{ *props.Transaction.Description }</p>
</div>
}
if props.RelatedTransaction != nil && props.RelatedAccount != nil {
{{
direction := "to"
if props.Transaction.Type == model.TransactionTypeDeposit {
direction = "from"
}
}}
<div>
<p class="text-sm text-muted-foreground">Transferred { direction }</p>
<a
class="font-medium underline-offset-2 hover:underline"
href={ templ.SafeURL(routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction", "spaceID", props.SpaceID, "accountID", props.RelatedAccount.ID, "transactionID", props.RelatedTransaction.ID)) }
>
{ props.RelatedAccount.Name }
</a>
</div>
}
}
}
@card.Card() {