feat: transaction activity audit and account activity audit
This commit is contained in:
parent
ca7b2ff74f
commit
c96595d41e
19 changed files with 1259 additions and 20 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
77
internal/service/account_activity.go
Normal file
77
internal/service/account_activity.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
78
internal/service/transaction_audit_log.go
Normal file
78
internal/service/transaction_audit_log.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue