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 { } } -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) { +
+ @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 { + + } + if props.DestErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.DestErr } + } + } + } +
+ @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 } + } + } + } +
+ @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 + } + } + } +
+} 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" + } + }} +
+

Transferred { direction }

+ + { props.RelatedAccount.Name } + +
+ } } } @card.Card() {