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 { *t.Description }
+ Move money out of { props.AccountName } into another account in this space.
+
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) {
Transfer Funds
+
{ *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 } + +