From ff237e2fab47b9cfd1dc326997299b855fcd47b0 Mon Sep 17 00:00:00 2001
From: juancwu
Date: Mon, 4 May 2026 02:18:30 +0000
Subject: [PATCH] feat: transfer funds between accounts
---
internal/handler/space.go | 293 ++++++++++++++++--
internal/repository/transaction.go | 101 ++++++
internal/repository/transaction_test.go | 113 +++++++
internal/routes/routes.go | 2 +
internal/service/transaction.go | 165 ++++++++++
internal/service/transaction_test.go | 235 ++++++++++++++
internal/ui/blocks/transaction_list.templ | 9 +-
internal/ui/forms/create_transfer.templ | 176 +++++++++++
internal/ui/pages/space_account.templ | 10 +-
.../ui/pages/space_account_activity.templ | 23 +-
.../ui/pages/space_account_transactions.templ | 26 +-
internal/ui/pages/space_create_transfer.templ | 32 ++
internal/ui/pages/space_overview.templ | 10 +
internal/ui/pages/space_transaction.templ | 51 ++-
14 files changed, 1186 insertions(+), 60 deletions(-)
create mode 100644 internal/repository/transaction_test.go
create mode 100644 internal/ui/forms/create_transfer.templ
create mode 100644 internal/ui/pages/space_create_transfer.templ
diff --git a/internal/handler/space.go b/internal/handler/space.go
index 869525a..00b83dd 100644
--- a/internal/handler/space.go
+++ b/internal/handler/space.go
@@ -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")
diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go
index f3aaee6..d304664 100644
--- a/internal/repository/transaction.go
+++ b/internal/repository/transaction.go
@@ -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)
diff --git a/internal/repository/transaction_test.go b/internal/repository/transaction_test.go
new file mode 100644
index 0000000..ebd2497
--- /dev/null
+++ b/internal/repository/transaction_test.go
@@ -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)
+ })
+}
diff --git a/internal/routes/routes.go b/internal/routes/routes.go
index b52668e..f3797b5 100644
--- a/internal/routes/routes.go
+++ b/internal/routes/routes.go
@@ -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")
})
})
})
diff --git a/internal/service/transaction.go b/internal/service/transaction.go
index 9e0d33b..557836f 100644
--- a/internal/service/transaction.go
+++ b/internal/service/transaction.go
@@ -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 " 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 {
diff --git a/internal/service/transaction_test.go b/internal/service/transaction_test.go
index 8433849..14741bc 100644
--- a/internal/service/transaction_test.go
+++ b/internal/service/transaction_test.go
@@ -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)
diff --git a/internal/ui/blocks/transaction_list.templ b/internal/ui/blocks/transaction_list.templ
index 5df15bf..436c3d6 100644
--- a/internal/ui/blocks/transaction_list.templ
+++ b/internal/ui/blocks/transaction_list.templ
@@ -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 {
for _, t := range props.Transactions {
- @transactionRow(props.SpaceID, props.AccountID, t)
+ @transactionRow(props.SpaceID, props.AccountID, t, !props.NonEditableIDs[t.ID])
}
}
}
-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) {
{ *t.Description }
}
- if spaceID != "" && accountID != "" {
+ if spaceID != "" && accountID != "" && editable {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
diff --git a/internal/ui/forms/create_transfer.templ b/internal/ui/forms/create_transfer.templ
new file mode 100644
index 0000000..35f56d1
--- /dev/null
+++ b/internal/ui/forms/create_transfer.templ
@@ -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) {
+
+}
diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ
index 37ce842..8e3fc74 100644
--- a/internal/ui/pages/space_account.templ
+++ b/internal/ui/pages/space_account.templ
@@ -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"}) {
diff --git a/internal/ui/pages/space_account_activity.templ b/internal/ui/pages/space_account_activity.templ
index dd2123a..57c36d3 100644
--- a/internal/ui/pages/space_account_activity.templ
+++ b/internal/ui/pages/space_account_activity.templ
@@ -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:
diff --git a/internal/ui/pages/space_account_transactions.templ b/internal/ui/pages/space_account_transactions.templ
index 6431d00..5d08c40 100644
--- a/internal/ui/pages/space_account_transactions.templ
+++ b/internal/ui/pages/space_account_transactions.templ
@@ -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 {
diff --git a/internal/ui/pages/space_create_transfer.templ b/internal/ui/pages/space_create_transfer.templ
new file mode 100644
index 0000000..5c5dccb
--- /dev/null
+++ b/internal/ui/pages/space_create_transfer.templ
@@ -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),
+ ) {
+
+
+
Transfer Funds
+
+ Move money out of { props.AccountName } into another account in this space.
+
+
+ @forms.CreateTransfer(props.Form)
+
+ }
+}
diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ
index 0bc06d1..a2e7334 100644
--- a/internal/ui/pages/space_overview.templ
+++ b/internal/ui/pages/space_overview.templ
@@ -144,6 +144,16 @@ templ spaceAccountSidebarContent(spaceID, accountID string) {
Deposit Funds
}
}
+ @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()
+ Transfer Funds
+ }
+ }
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.accounts.account.activity", "spaceID", spaceID, "accountID", accountID),
diff --git a/internal/ui/pages/space_transaction.templ b/internal/ui/pages/space_transaction.templ
index 0c5a716..d031e7b 100644
--- a/internal/ui/pages/space_transaction.templ
+++ b/internal/ui/pages/space_transaction.templ
@@ -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 }
- @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
+ }
}
@card.Card() {
@@ -100,6 +104,23 @@ templ SpaceTransactionPage(props SpaceTransactionPageProps) {
{ *props.Transaction.Description }
}
+ if props.RelatedTransaction != nil && props.RelatedAccount != nil {
+ {{
+ direction := "to"
+ if props.Transaction.Type == model.TransactionTypeDeposit {
+ direction = "from"
+ }
+ }}
+
+ }
}
}
@card.Card() {