feat: delete transactions

This commit is contained in:
juancwu 2026-05-04 14:52:51 +00:00
commit f371611017
6 changed files with 355 additions and 8 deletions

View file

@ -517,6 +517,64 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
return existing, nil
}
type DeleteTransactionInput struct {
TransactionID string
ActorID string
}
// DeleteTransaction removes a standalone bill or deposit. Transfers are
// rejected with ErrTransactionPartOfTransfer — they must be undone via the
// transfer flow so both halves stay consistent. Deleting a bill credits the
// account; deleting a deposit debits it.
func (s *TransactionService) DeleteTransaction(input DeleteTransactionInput) (*model.Transaction, error) {
if input.TransactionID == "" {
return nil, fmt.Errorf("transaction id is required")
}
existing, err := s.transactionRepo.GetByID(input.TransactionID)
if err != nil {
return nil, fmt.Errorf("failed to load transaction: %w", err)
}
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 {
return nil, fmt.Errorf("failed to load account: %w", err)
}
var newBalance decimal.Decimal
switch existing.Type {
case model.TransactionTypeWithdrawal:
newBalance = account.Balance.Add(existing.Value)
case model.TransactionTypeDeposit:
newBalance = account.Balance.Sub(existing.Value)
default:
return nil, fmt.Errorf("unsupported transaction type: %s", existing.Type)
}
if err := s.transactionRepo.DeleteAtomic(existing.ID, existing.AccountID, newBalance); err != nil {
return nil, fmt.Errorf("failed to delete transaction: %w", err)
}
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: existing.ID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionDeleted,
Metadata: map[string]any{
"account_id": existing.AccountID,
"transaction_type": string(existing.Type),
"title": existing.Title,
"amount": existing.Value.StringFixedBank(2),
},
})
return existing, nil
}
// diffTransactionFields returns a map of field name to {old, new} for fields whose
// new value differs from the existing transaction.
func diffTransactionFields(existing *model.Transaction, newTitle string, newAmount decimal.Decimal, newOccurredAt time.Time, newDescription *string) map[string]any {

View file

@ -481,6 +481,187 @@ func TestTransactionService_Update_RejectsTransferTransactions(t *testing.T) {
})
}
func TestTransactionService_DeleteTransaction_Bill_CreditsBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
// Seed 100 then pay a 30 bill, leaving balance at 70.
_, 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)
bill, err := f.svc.PayBill(PayBillInput{
AccountID: f.account.ID, Title: "Cable", Amount: decimal.NewFromInt(30),
OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
require.True(t, decimal.NewFromInt(70).Equal(updated.Balance))
deleted, err := f.svc.DeleteTransaction(DeleteTransactionInput{
TransactionID: bill.ID,
ActorID: f.user.ID,
})
require.NoError(t, err)
assert.Equal(t, bill.ID, deleted.ID)
// 70 + 30 = 100 (credit back).
updated, err = f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(100).Equal(updated.Balance))
// Transaction is gone.
_, err = f.svc.GetTransaction(bill.ID)
assert.Error(t, err)
// Audit log records the deletion (created + deleted, newest first).
logs, err := f.txAudit.ListByTransaction(bill.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 2)
assert.Equal(t, model.TransactionAuditActionDeleted, logs[0].Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "withdrawal", meta["transaction_type"])
assert.Equal(t, f.account.ID, meta["account_id"])
assert.Equal(t, "Cable", meta["title"])
assert.Equal(t, "30.00", meta["amount"])
})
}
func TestTransactionService_DeleteTransaction_Deposit_DebitsBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
dep, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "Paycheck", Amount: decimal.NewFromInt(150),
OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
require.True(t, decimal.NewFromInt(150).Equal(updated.Balance))
_, err = f.svc.DeleteTransaction(DeleteTransactionInput{
TransactionID: dep.ID,
ActorID: f.user.ID,
})
require.NoError(t, err)
// 150 - 150 = 0.
updated, err = f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.Zero.Equal(updated.Balance))
logs, err := f.txAudit.ListByTransaction(dep.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 2)
assert.Equal(t, model.TransactionAuditActionDeleted, logs[0].Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "deposit", meta["transaction_type"])
})
}
func TestTransactionService_DeleteTransaction_RejectsTransferHalves(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)
_, err = f.svc.DeleteTransaction(DeleteTransactionInput{
TransactionID: result.Withdrawal.ID,
ActorID: f.user.ID,
})
require.ErrorIs(t, err, ErrTransactionPartOfTransfer)
_, err = f.svc.DeleteTransaction(DeleteTransactionInput{
TransactionID: result.Deposit.ID,
ActorID: f.user.ID,
})
require.ErrorIs(t, err, ErrTransactionPartOfTransfer)
// Both halves still exist with untouched balances and no extra audit rows.
_, err = f.svc.GetTransaction(result.Withdrawal.ID)
require.NoError(t, err)
_, err = f.svc.GetTransaction(result.Deposit.ID)
require.NoError(t, err)
src, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(-20).Equal(src.Balance))
dst, err := f.accounts.ByID(dest.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(20).Equal(dst.Balance))
count, err := f.txAudit.CountByTransaction(result.Withdrawal.ID)
require.NoError(t, err)
assert.Equal(t, 1, count)
})
}
func TestTransactionService_DeleteTransaction_RemovesCategoryLink(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
categories, err := f.svc.ListCategories()
require.NoError(t, err)
require.NotEmpty(t, categories, "expected at least one seeded category")
categoryID := categories[0].ID
_, 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)
bill, err := f.svc.PayBill(PayBillInput{
AccountID: f.account.ID,
Title: "Groceries",
Amount: decimal.NewFromInt(40),
OccurredAt: time.Now(),
CategoryID: categoryID,
ActorID: f.user.ID,
})
require.NoError(t, err)
got, err := f.svc.GetTransactionCategoryID(bill.ID)
require.NoError(t, err)
require.Equal(t, categoryID, got)
_, err = f.svc.DeleteTransaction(DeleteTransactionInput{
TransactionID: bill.ID,
ActorID: f.user.ID,
})
require.NoError(t, err)
// transaction_categories cascades on the FK; the link is gone.
got, err = f.svc.GetTransactionCategoryID(bill.ID)
require.NoError(t, err)
assert.Equal(t, "", got)
})
}
func TestTransactionService_DeleteTransaction_RequiresID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
_, err := f.svc.DeleteTransaction(DeleteTransactionInput{ActorID: f.user.ID})
assert.Error(t, err)
})
}
func TestTransactionService_Validations(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)