feat: transaction activity audit and account activity audit

This commit is contained in:
juancwu 2026-05-03 23:50:39 +00:00
commit c96595d41e
19 changed files with 1259 additions and 20 deletions

View file

@ -13,13 +13,19 @@ const DefaultAccountName = "Money Account"
type AccountService struct {
accountRepo repository.AccountRepository
auditSvc *SpaceAuditLogService
}
func NewAccountService(accountRepo repository.AccountRepository) *AccountService {
return &AccountService{accountRepo: accountRepo}
}
func (s *AccountService) CreateAccount(spaceID, name string) (*model.Account, error) {
// SetAuditLogger wires the audit log service after construction.
func (s *AccountService) SetAuditLogger(audit *SpaceAuditLogService) {
s.auditSvc = audit
}
func (s *AccountService) CreateAccount(spaceID, name, actorID string) (*model.Account, error) {
if spaceID == "" {
return nil, fmt.Errorf("space id is required")
}
@ -38,6 +44,15 @@ func (s *AccountService) CreateAccount(spaceID, name string) (*model.Account, er
if err := s.accountRepo.Create(account); err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
s.auditSvc.Record(RecordOptions{
SpaceID: spaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountCreated,
Metadata: map[string]any{
"account_id": account.ID,
"account_name": account.Name,
},
})
return account, nil
}
@ -49,23 +64,54 @@ func (s *AccountService) GetAccount(id string) (*model.Account, error) {
return account, nil
}
func (s *AccountService) RenameAccount(id, name string) error {
func (s *AccountService) RenameAccount(id, name, actorID string) error {
if id == "" {
return fmt.Errorf("account id is required")
}
if name == "" {
return fmt.Errorf("account name cannot be empty")
}
current, err := s.accountRepo.ByID(id)
if err != nil {
return fmt.Errorf("failed to load account: %w", err)
}
oldName := current.Name
if err := s.accountRepo.Rename(id, name); err != nil {
return fmt.Errorf("failed to rename account: %w", err)
}
if oldName != name {
s.auditSvc.Record(RecordOptions{
SpaceID: current.SpaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountRenamed,
Metadata: map[string]any{
"account_id": id,
"old_name": oldName,
"new_name": name,
},
})
}
return nil
}
func (s *AccountService) DeleteAccount(id string) error {
func (s *AccountService) DeleteAccount(id, actorID string) error {
if id == "" {
return fmt.Errorf("account id is required")
}
current, err := s.accountRepo.ByID(id)
if err != nil {
return fmt.Errorf("failed to load account: %w", err)
}
// Record before deleting so the audit row references the pre-delete state.
s.auditSvc.Record(RecordOptions{
SpaceID: current.SpaceID,
ActorID: actorID,
Action: model.SpaceAuditActionAccountDeleted,
Metadata: map[string]any{
"account_id": id,
"account_name": current.Name,
},
})
if err := s.accountRepo.Delete(id); err != nil {
return fmt.Errorf("failed to delete account: %w", err)
}

View file

@ -0,0 +1,77 @@
package service
import (
"fmt"
"sort"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
type AccountActivityService struct {
spaceAudit *SpaceAuditLogService
txAudit *TransactionAuditLogService
}
func NewAccountActivityService(spaceAudit *SpaceAuditLogService, txAudit *TransactionAuditLogService) *AccountActivityService {
return &AccountActivityService{
spaceAudit: spaceAudit,
txAudit: txAudit,
}
}
// List returns a merged feed of account-scoped events (account.created/renamed/deleted)
// and transaction events (created/edited/deleted) for transactions in this account.
//
// Rather than a SQL UNION across two heterogeneous tables, we fetch up to (offset+limit)
// from each side and merge in Go. Audit volume per account is low, so the simplicity
// outweighs the slight overfetch.
func (s *AccountActivityService) List(accountID string, limit, offset int) ([]model.AccountActivityRow, error) {
if limit <= 0 {
limit = 25
}
if offset < 0 {
offset = 0
}
fetchN := offset + limit
spaceLogs, err := s.spaceAudit.repo.ListAccountEvents(accountID, fetchN, 0)
if err != nil {
return nil, fmt.Errorf("failed to list account events: %w", err)
}
txLogs, err := s.txAudit.repo.ListByAccount(accountID, fetchN, 0)
if err != nil {
return nil, fmt.Errorf("failed to list transaction events: %w", err)
}
rows := make([]model.AccountActivityRow, 0, len(spaceLogs)+len(txLogs))
for _, l := range spaceLogs {
rows = append(rows, model.AccountActivityRow{SpaceLog: l})
}
for _, l := range txLogs {
rows = append(rows, model.AccountActivityRow{TxLog: l})
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].Timestamp().After(rows[j].Timestamp())
})
if offset >= len(rows) {
return nil, nil
}
end := offset + limit
if end > len(rows) {
end = len(rows)
}
return rows[offset:end], nil
}
func (s *AccountActivityService) Count(accountID string) (int, error) {
spaceCount, err := s.spaceAudit.repo.CountAccountEvents(accountID)
if err != nil {
return 0, fmt.Errorf("failed to count account events: %w", err)
}
txCount, err := s.txAudit.repo.CountByAccount(accountID)
if err != nil {
return 0, fmt.Errorf("failed to count transaction events: %w", err)
}
return spaceCount + txCount, nil
}

View file

@ -363,7 +363,7 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return fmt.Errorf("failed to create onboarding space: %w", err)
}
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName); err != nil {
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName, userID); err != nil {
if delErr := s.spaceService.DeleteSpace(space.ID, userID); delErr != nil {
slog.Error("failed to roll back space after account creation error",
"space_id", space.ID, "error", delErr)

View file

@ -15,6 +15,7 @@ type TransactionService struct {
transactionRepo repository.TransactionRepository
categoryRepo repository.CategoryRepository
accountService *AccountService
auditSvc *TransactionAuditLogService
}
func NewTransactionService(
@ -29,6 +30,11 @@ func NewTransactionService(
}
}
// SetAuditLogger wires the audit log service after construction.
func (s *TransactionService) SetAuditLogger(audit *TransactionAuditLogService) {
s.auditSvc = audit
}
type PayBillInput struct {
AccountID string
Title string
@ -36,6 +42,7 @@ type PayBillInput struct {
OccurredAt time.Time
Description string
CategoryID string
ActorID string
}
func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, error) {
@ -86,6 +93,18 @@ func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, er
return nil, fmt.Errorf("failed to create bill transaction: %w", err)
}
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: txn.ID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionCreated,
Metadata: map[string]any{
"account_id": txn.AccountID,
"transaction_type": string(model.TransactionTypeWithdrawal),
"title": txn.Title,
"amount": txn.Value.StringFixedBank(2),
},
})
return txn, nil
}
@ -95,6 +114,7 @@ type DepositInput struct {
Amount decimal.Decimal
OccurredAt time.Time
Description string
ActorID string
}
func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, error) {
@ -141,6 +161,18 @@ func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, er
return nil, fmt.Errorf("failed to create deposit transaction: %w", err)
}
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: txn.ID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionCreated,
Metadata: map[string]any{
"account_id": txn.AccountID,
"transaction_type": string(model.TransactionTypeDeposit),
"title": txn.Title,
"amount": txn.Value.StringFixedBank(2),
},
})
return txn, nil
}
@ -151,6 +183,7 @@ type UpdateBillInput struct {
OccurredAt time.Time
Description string
CategoryID string
ActorID string
}
func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transaction, error) {
@ -192,6 +225,15 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti
categoryID = &c
}
oldCategoryID, _ := s.transactionRepo.GetCategoryID(input.TransactionID)
changes := diffTransactionFields(existing, title, input.Amount, input.OccurredAt, description)
if !ptrEq(oldCategoryID, categoryID) {
changes["category_id"] = map[string]any{
"old": ptrOrEmpty(oldCategoryID),
"new": ptrOrEmpty(categoryID),
}
}
existing.Value = input.Amount
existing.Title = title
existing.Description = description
@ -201,6 +243,14 @@ func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transacti
if err := s.transactionRepo.UpdateBillAtomic(existing, newBalance, categoryID); err != nil {
return nil, fmt.Errorf("failed to update bill transaction: %w", err)
}
if len(changes) > 0 {
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: input.TransactionID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionEdited,
Metadata: map[string]any{"changes": changes},
})
}
return existing, nil
}
@ -210,6 +260,7 @@ type UpdateDepositInput struct {
Amount decimal.Decimal
OccurredAt time.Time
Description string
ActorID string
}
func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Transaction, error) {
@ -247,6 +298,8 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
description = &d
}
changes := diffTransactionFields(existing, title, input.Amount, input.OccurredAt, description)
existing.Value = input.Amount
existing.Title = title
existing.Description = description
@ -256,9 +309,66 @@ func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Tra
if err := s.transactionRepo.UpdateDepositAtomic(existing, newBalance); err != nil {
return nil, fmt.Errorf("failed to update deposit transaction: %w", err)
}
if len(changes) > 0 {
s.auditSvc.Record(TransactionRecordOptions{
TransactionID: input.TransactionID,
ActorID: input.ActorID,
Action: model.TransactionAuditActionEdited,
Metadata: map[string]any{"changes": changes},
})
}
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 {
changes := map[string]any{}
if existing.Title != newTitle {
changes["title"] = map[string]any{"old": existing.Title, "new": newTitle}
}
if !existing.Value.Equal(newAmount) {
changes["amount"] = map[string]any{
"old": existing.Value.StringFixedBank(2),
"new": newAmount.StringFixedBank(2),
}
}
if !existing.OccurredAt.Equal(newOccurredAt) {
changes["occurred_at"] = map[string]any{
"old": existing.OccurredAt.Format("2006-01-02"),
"new": newOccurredAt.Format("2006-01-02"),
}
}
if !ptrStringEq(existing.Description, newDescription) {
changes["description"] = map[string]any{
"old": ptrOrEmpty(existing.Description),
"new": ptrOrEmpty(newDescription),
}
}
return changes
}
func ptrStringEq(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func ptrEq(a, b *string) bool {
return ptrStringEq(a, b)
}
func ptrOrEmpty(p *string) string {
if p == nil {
return ""
}
return *p
}
func (s *TransactionService) GetTransaction(id string) (*model.Transaction, error) {
txn, err := s.transactionRepo.GetByID(id)
if err != nil {

View file

@ -0,0 +1,78 @@
package service
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid"
)
type TransactionAuditLogService struct {
repo repository.TransactionAuditLogRepository
}
func NewTransactionAuditLogService(repo repository.TransactionAuditLogRepository) *TransactionAuditLogService {
return &TransactionAuditLogService{repo: repo}
}
type TransactionRecordOptions struct {
TransactionID string
ActorID string
Action model.TransactionAuditAction
Metadata map[string]any
}
// Record persists a transaction audit entry. Failures are logged but never bubble up —
// auditing must not break the user-facing action that triggered it. A nil receiver is
// a no-op so tests can omit the dependency.
func (s *TransactionAuditLogService) Record(opts TransactionRecordOptions) {
if s == nil {
return
}
entry := &model.TransactionAuditLog{
ID: uuid.NewString(),
TransactionID: opts.TransactionID,
Action: opts.Action,
CreatedAt: time.Now(),
}
if opts.ActorID != "" {
actor := opts.ActorID
entry.ActorID = &actor
}
if len(opts.Metadata) > 0 {
raw, err := json.Marshal(opts.Metadata)
if err != nil {
slog.Error("failed to marshal transaction audit metadata", "error", err, "action", opts.Action)
} else {
entry.Metadata = raw
}
}
if err := s.repo.Create(entry); err != nil {
slog.Error("failed to record transaction audit log",
"error", err,
"transaction_id", opts.TransactionID,
"action", opts.Action,
)
}
}
func (s *TransactionAuditLogService) List(transactionID string, limit, offset int) ([]*model.TransactionAuditLogWithActor, error) {
logs, err := s.repo.ListByTransaction(transactionID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list transaction audit logs: %w", err)
}
return logs, nil
}
func (s *TransactionAuditLogService) Count(transactionID string) (int, error) {
count, err := s.repo.CountByTransaction(transactionID)
if err != nil {
return 0, fmt.Errorf("failed to count transaction audit logs: %w", err)
}
return count, nil
}