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