This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,608 @@
package service
import (
"errors"
"fmt"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"gorm.io/gorm"
)
// Transaction service errors
var (
ErrTransactionNotFound = errors.New("transaction not found")
ErrInvalidTransactionType = errors.New("invalid transaction type")
ErrMissingRequiredField = errors.New("missing required field")
ErrInvalidAmount = errors.New("amount must be positive")
ErrInvalidCurrency = errors.New("invalid currency")
ErrCategoryNotFoundForTxn = errors.New("category not found")
ErrAccountNotFoundForTxn = errors.New("account not found")
ErrToAccountNotFoundForTxn = errors.New("destination account not found for transfer")
ErrToAccountRequiredForTxn = errors.New("destination account is required for transfer transactions")
ErrSameAccountTransferForTxn = errors.New("cannot transfer to the same account")
)
// TransactionInput represents the input data for creating or updating a transaction
type TransactionInput struct {
UserID uint `json:"user_id"`
Amount float64 `json:"amount" binding:"required"`
Type models.TransactionType `json:"type" binding:"required"`
CategoryID uint `json:"category_id" binding:"required"`
AccountID uint `json:"account_id" binding:"required"`
Currency models.Currency `json:"currency" binding:"required"`
TransactionDate time.Time `json:"transaction_date" binding:"required"`
Note string `json:"note,omitempty"`
ImagePath string `json:"image_path,omitempty"`
ToAccountID *uint `json:"to_account_id,omitempty"`
TagIDs []uint `json:"tag_ids,omitempty"`
}
// TransactionListInput represents the input for listing transactions
type TransactionListInput struct {
UserID *uint `json:"user_id,omitempty"`
StartDate *time.Time `json:"start_date,omitempty"`
EndDate *time.Time `json:"end_date,omitempty"`
CategoryID *uint `json:"category_id,omitempty"`
AccountID *uint `json:"account_id,omitempty"`
TagIDs []uint `json:"tag_ids,omitempty"`
Type *models.TransactionType `json:"type,omitempty"`
Currency *models.Currency `json:"currency,omitempty"`
NoteSearch string `json:"note_search,omitempty"`
SortField string `json:"sort_field,omitempty"`
SortAsc bool `json:"sort_asc,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
}
// TransactionService handles business logic for transactions
type TransactionService struct {
repo *repository.TransactionRepository
accountRepo *repository.AccountRepository
categoryRepo *repository.CategoryRepository
tagRepo *repository.TagRepository
db *gorm.DB
}
// NewTransactionService creates a new TransactionService instance
func NewTransactionService(
repo *repository.TransactionRepository,
accountRepo *repository.AccountRepository,
categoryRepo *repository.CategoryRepository,
tagRepo *repository.TagRepository,
db *gorm.DB,
) *TransactionService {
return &TransactionService{
repo: repo,
accountRepo: accountRepo,
categoryRepo: categoryRepo,
tagRepo: tagRepo,
db: db,
}
}
// ValidateTransactionInput validates the required fields for a transaction
// Returns an error if any required field is missing or invalid
// Required fields: Amount, Type, CategoryID, AccountID, Currency, TransactionDate
func (s *TransactionService) ValidateTransactionInput(input TransactionInput) error {
// Validate amount (must be positive)
if input.Amount <= 0 {
return fmt.Errorf("%w: amount must be greater than 0", ErrInvalidAmount)
}
// Validate transaction type
if input.Type == "" {
return fmt.Errorf("%w: type", ErrMissingRequiredField)
}
if input.Type != models.TransactionTypeIncome &&
input.Type != models.TransactionTypeExpense &&
input.Type != models.TransactionTypeTransfer {
return ErrInvalidTransactionType
}
// Validate category ID
if input.CategoryID == 0 {
return fmt.Errorf("%w: category_id", ErrMissingRequiredField)
}
// Validate account ID
if input.AccountID == 0 {
return fmt.Errorf("%w: account_id", ErrMissingRequiredField)
}
// Validate currency
if input.Currency == "" {
return fmt.Errorf("%w: currency", ErrMissingRequiredField)
}
if !isValidCurrency(input.Currency) {
return ErrInvalidCurrency
}
// Validate transaction date
if input.TransactionDate.IsZero() {
return fmt.Errorf("%w: transaction_date", ErrMissingRequiredField)
}
// Validate transfer-specific fields
if input.Type == models.TransactionTypeTransfer {
if input.ToAccountID == nil || *input.ToAccountID == 0 {
return ErrToAccountRequiredForTxn
}
if *input.ToAccountID == input.AccountID {
return ErrSameAccountTransferForTxn
}
}
return nil
}
// isValidCurrency checks if the currency is a supported currency
func isValidCurrency(currency models.Currency) bool {
supportedCurrencies := models.SupportedCurrencies()
for _, c := range supportedCurrencies {
if c == currency {
return true
}
}
return false
}
// CreateTransaction creates a new transaction with business logic validation
// and automatically updates the account balance
func (s *TransactionService) CreateTransaction(userID uint, input TransactionInput) (*models.Transaction, error) {
input.UserID = userID
// Validate input
if err := s.ValidateTransactionInput(input); err != nil {
return nil, err
}
// Execute within a database transaction
var transaction *models.Transaction
err := s.db.Transaction(func(tx *gorm.DB) error {
// Create temporary repositories for this transaction
txAccountRepo := repository.NewAccountRepository(tx)
txCategoryRepo := repository.NewCategoryRepository(tx)
txTransactionRepo := repository.NewTransactionRepository(tx)
// Verify category exists
categoryExists, err := txCategoryRepo.ExistsByID(userID, input.CategoryID)
if err != nil {
return fmt.Errorf("failed to verify category: %w", err)
}
if !categoryExists {
return ErrCategoryNotFoundForTxn
}
// Verify account exists and get it for balance update
account, err := txAccountRepo.GetByID(userID, input.AccountID)
if err != nil {
if errors.Is(err, repository.ErrAccountNotFound) {
return ErrAccountNotFoundForTxn
}
return fmt.Errorf("failed to verify account: %w", err)
}
// For transfer transactions, verify destination account
var toAccount *models.Account
if input.Type == models.TransactionTypeTransfer {
toAccount, err = txAccountRepo.GetByID(userID, *input.ToAccountID)
if err != nil {
if errors.Is(err, repository.ErrAccountNotFound) {
return ErrToAccountNotFoundForTxn
}
return fmt.Errorf("failed to verify destination account: %w", err)
}
}
// Calculate new balance and validate
newBalance := calculateNewBalance(account.Balance, input.Amount, input.Type, true)
if !account.IsCredit && newBalance < 0 {
return ErrInsufficientBalance
}
// Create the transaction model
transaction = &models.Transaction{
UserID: input.UserID,
Amount: input.Amount,
Type: input.Type,
CategoryID: input.CategoryID,
AccountID: input.AccountID,
Currency: input.Currency,
TransactionDate: input.TransactionDate,
Note: input.Note,
ImagePath: input.ImagePath,
ToAccountID: input.ToAccountID,
}
// Save transaction with tags
if len(input.TagIDs) > 0 {
// Validate tags ownership
for _, tagID := range input.TagIDs {
exists, err := s.tagRepo.ExistsByID(userID, tagID)
if err != nil {
return fmt.Errorf("failed to verify tag %d: %w", tagID, err)
}
if !exists {
return fmt.Errorf("tag %d not found or not owned by user", tagID)
}
}
if err := txTransactionRepo.CreateWithTags(transaction, input.TagIDs); err != nil {
return fmt.Errorf("failed to create transaction with tags: %w", err)
}
} else {
if err := txTransactionRepo.Create(transaction); err != nil {
return fmt.Errorf("failed to create transaction: %w", err)
}
}
// Update account balance
if err := txAccountRepo.UpdateBalance(userID, input.AccountID, newBalance); err != nil {
return fmt.Errorf("failed to update account balance: %w", err)
}
// For transfer transactions, update destination account balance
if input.Type == models.TransactionTypeTransfer && toAccount != nil {
newToBalance := toAccount.Balance + input.Amount
if err := txAccountRepo.UpdateBalance(userID, *input.ToAccountID, newToBalance); err != nil {
return fmt.Errorf("failed to update destination account balance: %w", err)
}
}
return nil
})
if err != nil {
return nil, err
}
return transaction, nil
}
// GetTransaction retrieves a transaction by ID
// GetTransaction retrieves a transaction by ID and verifies ownership
func (s *TransactionService) GetTransaction(userID, id uint) (*models.Transaction, error) {
transaction, err := s.repo.GetByIDWithRelations(userID, id)
if err != nil {
if errors.Is(err, repository.ErrTransactionNotFound) {
return nil, ErrTransactionNotFound
}
return nil, fmt.Errorf("failed to get transaction: %w", err)
}
if transaction.UserID != userID {
return nil, ErrTransactionNotFound
}
return transaction, nil
}
// UpdateTransaction updates an existing transaction and adjusts account balances, verifying ownership
func (s *TransactionService) UpdateTransaction(userID, id uint, input TransactionInput) (*models.Transaction, error) {
// Validate input
if err := s.ValidateTransactionInput(input); err != nil {
return nil, err
}
var transaction *models.Transaction
err := s.db.Transaction(func(tx *gorm.DB) error {
// Create temporary repositories for this transaction
txAccountRepo := repository.NewAccountRepository(tx)
txCategoryRepo := repository.NewCategoryRepository(tx)
txTransactionRepo := repository.NewTransactionRepository(tx)
// Get existing transaction
existingTxn, err := txTransactionRepo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrTransactionNotFound) {
return ErrTransactionNotFound
}
return fmt.Errorf("failed to get existing transaction: %w", err)
}
if existingTxn.UserID != userID {
return ErrTransactionNotFound
}
// Verify category exists
categoryExists, err := txCategoryRepo.ExistsByID(userID, input.CategoryID)
if err != nil {
return fmt.Errorf("failed to verify category: %w", err)
}
if !categoryExists {
return ErrCategoryNotFoundForTxn
}
// Get old account for balance reversal
oldAccount, err := txAccountRepo.GetByID(userID, existingTxn.AccountID)
if err != nil {
return fmt.Errorf("failed to get old account: %w", err)
}
// Get new account
newAccount, err := txAccountRepo.GetByID(userID, input.AccountID)
if err != nil {
if errors.Is(err, repository.ErrAccountNotFound) {
return ErrAccountNotFoundForTxn
}
return fmt.Errorf("failed to get new account: %w", err)
}
// Handle old transfer destination account
var oldToAccount *models.Account
if existingTxn.Type == models.TransactionTypeTransfer && existingTxn.ToAccountID != nil {
oldToAccount, err = txAccountRepo.GetByID(userID, *existingTxn.ToAccountID)
if err != nil && !errors.Is(err, repository.ErrAccountNotFound) {
return fmt.Errorf("failed to get old destination account: %w", err)
}
}
// Handle new transfer destination account
var newToAccount *models.Account
if input.Type == models.TransactionTypeTransfer {
newToAccount, err = txAccountRepo.GetByID(userID, *input.ToAccountID)
if err != nil {
if errors.Is(err, repository.ErrAccountNotFound) {
return ErrToAccountNotFoundForTxn
}
return fmt.Errorf("failed to get new destination account: %w", err)
}
}
// Step 1: Reverse the old transaction's effect on balances
oldReversedBalance := calculateNewBalance(oldAccount.Balance, existingTxn.Amount, existingTxn.Type, false)
if err := txAccountRepo.UpdateBalance(userID, existingTxn.AccountID, oldReversedBalance); err != nil {
return fmt.Errorf("failed to reverse old account balance: %w", err)
}
// Reverse old transfer destination if applicable
if oldToAccount != nil {
oldToReversedBalance := oldToAccount.Balance - existingTxn.Amount
if err := txAccountRepo.UpdateBalance(userID, *existingTxn.ToAccountID, oldToReversedBalance); err != nil {
return fmt.Errorf("failed to reverse old destination account balance: %w", err)
}
}
// Step 2: Apply the new transaction's effect on balances
// Re-fetch account if it's the same as old account (balance was just updated)
if input.AccountID == existingTxn.AccountID {
newAccount, err = txAccountRepo.GetByID(userID, input.AccountID)
if err != nil {
return fmt.Errorf("failed to re-fetch account: %w", err)
}
}
newBalance := calculateNewBalance(newAccount.Balance, input.Amount, input.Type, true)
if !newAccount.IsCredit && newBalance < 0 {
return ErrInsufficientBalance
}
if err := txAccountRepo.UpdateBalance(userID, input.AccountID, newBalance); err != nil {
return fmt.Errorf("failed to update new account balance: %w", err)
}
// Apply new transfer destination if applicable
if newToAccount != nil {
// Re-fetch if it's the same as old destination (balance was just updated)
if existingTxn.ToAccountID != nil && *input.ToAccountID == *existingTxn.ToAccountID {
newToAccount, err = txAccountRepo.GetByID(userID, *input.ToAccountID)
if err != nil {
return fmt.Errorf("failed to re-fetch destination account: %w", err)
}
}
newToBalance := newToAccount.Balance + input.Amount
if err := txAccountRepo.UpdateBalance(userID, *input.ToAccountID, newToBalance); err != nil {
return fmt.Errorf("failed to update new destination account balance: %w", err)
}
}
// Step 3: Update the transaction record
transaction = existingTxn
transaction.Amount = input.Amount
transaction.Type = input.Type
transaction.CategoryID = input.CategoryID
transaction.AccountID = input.AccountID
transaction.Currency = input.Currency
transaction.TransactionDate = input.TransactionDate
transaction.Note = input.Note
transaction.ImagePath = input.ImagePath
transaction.ToAccountID = input.ToAccountID
if err := txTransactionRepo.UpdateWithTags(transaction, input.TagIDs); err != nil {
return fmt.Errorf("failed to update transaction: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return transaction, nil
}
// DeleteTransaction deletes a transaction and reverses its effect on account balance, verifying ownership
func (s *TransactionService) DeleteTransaction(userID, id uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// Create temporary repositories for this transaction
txAccountRepo := repository.NewAccountRepository(tx)
txTransactionRepo := repository.NewTransactionRepository(tx)
// Get existing transaction
existingTxn, err := txTransactionRepo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrTransactionNotFound) {
return ErrTransactionNotFound
}
return fmt.Errorf("failed to get transaction: %w", err)
}
if existingTxn.UserID != userID {
return ErrTransactionNotFound
}
// Get account for balance reversal
account, err := txAccountRepo.GetByID(userID, existingTxn.AccountID)
if err != nil {
return fmt.Errorf("failed to get account: %w", err)
}
// Reverse the transaction's effect on balance
reversedBalance := calculateNewBalance(account.Balance, existingTxn.Amount, existingTxn.Type, false)
if err := txAccountRepo.UpdateBalance(userID, existingTxn.AccountID, reversedBalance); err != nil {
return fmt.Errorf("failed to reverse account balance: %w", err)
}
// For transfer transactions, reverse destination account balance
if existingTxn.Type == models.TransactionTypeTransfer && existingTxn.ToAccountID != nil {
toAccount, err := txAccountRepo.GetByID(userID, *existingTxn.ToAccountID)
if err != nil && !errors.Is(err, repository.ErrAccountNotFound) {
return fmt.Errorf("failed to get destination account: %w", err)
}
if toAccount != nil {
reversedToBalance := toAccount.Balance - existingTxn.Amount
if err := txAccountRepo.UpdateBalance(userID, *existingTxn.ToAccountID, reversedToBalance); err != nil {
return fmt.Errorf("failed to reverse destination account balance: %w", err)
}
}
}
// Delete the transaction
if err := txTransactionRepo.Delete(userID, id); err != nil {
return fmt.Errorf("failed to delete transaction: %w", err)
}
return nil
})
}
// ListTransactions retrieves transactions with filtering and pagination
func (s *TransactionService) ListTransactions(userID uint, input TransactionListInput) (*repository.TransactionListResult, error) {
// Set default limit if not provided
limit := input.Limit
if limit <= 0 {
limit = 20
}
options := repository.TransactionListOptions{
Filter: repository.TransactionFilter{
UserID: &userID,
StartDate: input.StartDate,
EndDate: input.EndDate,
CategoryID: input.CategoryID,
AccountID: input.AccountID,
TagIDs: input.TagIDs,
Type: input.Type,
Currency: input.Currency,
NoteSearch: input.NoteSearch,
},
Sort: repository.TransactionSort{
Field: input.SortField,
Ascending: input.SortAsc,
},
Offset: input.Offset,
Limit: limit,
}
result, err := s.repo.List(userID, options)
if err != nil {
return nil, fmt.Errorf("failed to list transactions: %w", err)
}
return result, nil
}
// GetTransactionsByAccount retrieves all transactions for a specific account
func (s *TransactionService) GetTransactionsByAccount(userID uint, accountID uint) ([]models.Transaction, error) {
// Verify account exists
_, err := s.accountRepo.GetByID(userID, accountID)
if err != nil {
if errors.Is(err, repository.ErrAccountNotFound) {
return nil, ErrAccountNotFoundForTxn
}
return nil, fmt.Errorf("failed to verify account: %w", err)
}
transactions, err := s.repo.GetByAccountID(userID, accountID)
if err != nil {
return nil, fmt.Errorf("failed to get transactions by account: %w", err)
}
return transactions, nil
}
// GetTransactionsByCategory retrieves all transactions for a specific category
func (s *TransactionService) GetTransactionsByCategory(userID uint, categoryID uint) ([]models.Transaction, error) {
// Verify category exists
exists, err := s.categoryRepo.ExistsByID(userID, categoryID)
if err != nil {
return nil, fmt.Errorf("failed to verify category: %w", err)
}
if !exists {
return nil, ErrCategoryNotFoundForTxn
}
transactions, err := s.repo.GetByCategoryID(userID, categoryID)
if err != nil {
return nil, fmt.Errorf("failed to get transactions by category: %w", err)
}
return transactions, nil
}
// GetTransactionsByDateRange retrieves all transactions within a date range
func (s *TransactionService) GetTransactionsByDateRange(userID uint, startDate, endDate time.Time) ([]models.Transaction, error) {
transactions, err := s.repo.GetByDateRange(userID, startDate, endDate)
if err != nil {
return nil, fmt.Errorf("failed to get transactions by date range: %w", err)
}
return transactions, nil
}
// GetRecentTransactions retrieves the most recent transactions
func (s *TransactionService) GetRecentTransactions(userID uint, limit int) ([]models.Transaction, error) {
if limit <= 0 {
limit = 10
}
transactions, err := s.repo.GetRecentTransactions(userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get recent transactions: %w", err)
}
return transactions, nil
}
// GetRelatedTransactions retrieves all related transactions for a given transaction ID
// Returns the relationship between original expense/refund income/reimbursement income
// Feature: accounting-feature-upgrade
// Validates: Requirements 8.21, 8.22
func (s *TransactionService) GetRelatedTransactions(userID uint, id uint) ([]models.Transaction, error) {
relatedTransactions, err := s.repo.GetRelatedTransactions(userID, id)
if err != nil {
if errors.Is(err, repository.ErrTransactionNotFound) {
return nil, ErrTransactionNotFound
}
return nil, fmt.Errorf("failed to get related transactions: %w", err)
}
return relatedTransactions, nil
}
// calculateNewBalance calculates the new balance after a transaction
// isApply: true for applying a transaction, false for reversing it
func calculateNewBalance(currentBalance, amount float64, txnType models.TransactionType, isApply bool) float64 {
var change float64
switch txnType {
case models.TransactionTypeIncome:
change = amount
case models.TransactionTypeExpense:
change = -amount
case models.TransactionTypeTransfer:
// For the source account, transfer is like an expense
change = -amount
default:
return currentBalance
}
if isApply {
return currentBalance + change
}
// Reverse: subtract the change (or add the negative)
return currentBalance - change
}