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 }