package service import ( "errors" "fmt" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // RecurringTransactionService handles business logic for recurring transactions type RecurringTransactionService struct { recurringRepo *repository.RecurringTransactionRepository transactionRepo *repository.TransactionRepository accountRepo *repository.AccountRepository categoryRepo *repository.CategoryRepository allocationRuleRepo *repository.AllocationRuleRepository recordRepo *repository.AllocationRecordRepository piggyBankRepo *repository.PiggyBankRepository db *gorm.DB } // NewRecurringTransactionService creates a new RecurringTransactionService instance func NewRecurringTransactionService( recurringRepo *repository.RecurringTransactionRepository, transactionRepo *repository.TransactionRepository, accountRepo *repository.AccountRepository, categoryRepo *repository.CategoryRepository, allocationRuleRepo *repository.AllocationRuleRepository, recordRepo *repository.AllocationRecordRepository, piggyBankRepo *repository.PiggyBankRepository, db *gorm.DB, ) *RecurringTransactionService { return &RecurringTransactionService{ recurringRepo: recurringRepo, transactionRepo: transactionRepo, accountRepo: accountRepo, categoryRepo: categoryRepo, allocationRuleRepo: allocationRuleRepo, recordRepo: recordRepo, piggyBankRepo: piggyBankRepo, db: db, } } // CreateRecurringTransactionRequest represents the request to create a recurring transaction type CreateRecurringTransactionRequest struct { UserID uint `json:"user_id"` Amount float64 `json:"amount" binding:"required,gt=0"` Type models.TransactionType `json:"type" binding:"required,oneof=income expense"` CategoryID uint `json:"category_id" binding:"required"` AccountID uint `json:"account_id" binding:"required"` Currency models.Currency `json:"currency" binding:"required"` Note string `json:"note"` Frequency models.FrequencyType `json:"frequency" binding:"required,oneof=daily weekly monthly yearly"` StartDate time.Time `json:"start_date" binding:"required"` EndDate *time.Time `json:"end_date"` } // UpdateRecurringTransactionRequest represents the request to update a recurring transaction type UpdateRecurringTransactionRequest struct { Amount *float64 `json:"amount" binding:"omitempty,gt=0"` Type *models.TransactionType `json:"type" binding:"omitempty,oneof=income expense"` CategoryID *uint `json:"category_id"` AccountID *uint `json:"account_id"` Currency *models.Currency `json:"currency"` Note *string `json:"note"` Frequency *models.FrequencyType `json:"frequency" binding:"omitempty,oneof=daily weekly monthly yearly"` StartDate *time.Time `json:"start_date"` EndDate *time.Time `json:"end_date"` ClearEndDate bool `json:"clear_end_date"` // 璁句负true鏃舵竻闄ょ粨鏉熸棩鏈? IsActive *bool `json:"is_active"` } // Create creates a new recurring transaction func (s *RecurringTransactionService) Create(req CreateRecurringTransactionRequest) (*models.RecurringTransaction, error) { // Validate account exists account, err := s.accountRepo.GetByID(req.UserID, req.AccountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, fmt.Errorf("account not found") } return nil, fmt.Errorf("failed to validate account: %w", err) } // Validate category exists _, err = s.categoryRepo.GetByID(req.UserID, req.CategoryID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, fmt.Errorf("category not found") } return nil, fmt.Errorf("failed to validate category: %w", err) } // Validate currency matches account currency if req.Currency != account.Currency { return nil, fmt.Errorf("currency mismatch: transaction currency %s does not match account currency %s", req.Currency, account.Currency) } // Validate end date is after start date if req.EndDate != nil && req.EndDate.Before(req.StartDate) { return nil, fmt.Errorf("end date must be after start date") } // Calculate next occurrence (first occurrence is the start date) nextOccurrence := req.StartDate recurringTransaction := &models.RecurringTransaction{ UserID: req.UserID, Amount: req.Amount, Type: req.Type, CategoryID: req.CategoryID, AccountID: req.AccountID, Currency: req.Currency, Note: req.Note, Frequency: req.Frequency, StartDate: req.StartDate, EndDate: req.EndDate, NextOccurrence: nextOccurrence, IsActive: true, } if err := s.recurringRepo.Create(recurringTransaction); err != nil { return nil, fmt.Errorf("failed to create recurring transaction: %w", err) } return recurringTransaction, nil } // GetByID retrieves a recurring transaction by its ID and verifies ownership func (s *RecurringTransactionService) GetByID(userID, id uint) (*models.RecurringTransaction, error) { recurringTransaction, err := s.recurringRepo.GetByIDWithRelations(userID, id) if err != nil { return nil, err } if recurringTransaction.UserID != userID { return nil, repository.ErrRecurringTransactionNotFound } return recurringTransaction, nil } // Update updates an existing recurring transaction after verifying ownership func (s *RecurringTransactionService) Update(userID, id uint, req UpdateRecurringTransactionRequest) (*models.RecurringTransaction, error) { // Get existing recurring transaction recurringTransaction, err := s.recurringRepo.GetByID(userID, id) if err != nil { return nil, err } if recurringTransaction.UserID != userID { return nil, repository.ErrRecurringTransactionNotFound } // Update fields if provided if req.Amount != nil { recurringTransaction.Amount = *req.Amount } if req.Type != nil { recurringTransaction.Type = *req.Type } if req.CategoryID != nil { // Validate category exists _, err := s.categoryRepo.GetByID(userID, *req.CategoryID) if err != nil { if errors.Is(err, repository.ErrCategoryNotFound) { return nil, fmt.Errorf("category not found") } return nil, fmt.Errorf("failed to validate category: %w", err) } recurringTransaction.CategoryID = *req.CategoryID } if req.AccountID != nil { // Validate account exists account, err := s.accountRepo.GetByID(userID, *req.AccountID) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, fmt.Errorf("account not found") } return nil, fmt.Errorf("failed to validate account: %w", err) } // Validate currency matches if currency is not being updated if req.Currency == nil && recurringTransaction.Currency != account.Currency { return nil, fmt.Errorf("currency mismatch: transaction currency %s does not match account currency %s", recurringTransaction.Currency, account.Currency) } recurringTransaction.AccountID = *req.AccountID } if req.Currency != nil { // Validate currency matches account account, err := s.accountRepo.GetByID(userID, recurringTransaction.AccountID) if err != nil { return nil, fmt.Errorf("failed to validate account: %w", err) } if *req.Currency != account.Currency { return nil, fmt.Errorf("currency mismatch: transaction currency %s does not match account currency %s", *req.Currency, account.Currency) } recurringTransaction.Currency = *req.Currency } if req.Note != nil { recurringTransaction.Note = *req.Note } if req.Frequency != nil { recurringTransaction.Frequency = *req.Frequency // Recalculate next occurrence with new frequency recurringTransaction.NextOccurrence = s.CalculateNextOccurrence(recurringTransaction.NextOccurrence, *req.Frequency) } if req.StartDate != nil { recurringTransaction.StartDate = *req.StartDate } if req.ClearEndDate { // 娓呴櫎缁撴潫鏃ユ湡 recurringTransaction.EndDate = nil } else if req.EndDate != nil { // 楠岃瘉缁撴潫鏃ユ湡蹇呴』鍦ㄥ紑濮嬫棩鏈熶箣鍚? if req.EndDate.Before(recurringTransaction.StartDate) { return nil, fmt.Errorf("end date must be after start date") } recurringTransaction.EndDate = req.EndDate } if req.IsActive != nil { recurringTransaction.IsActive = *req.IsActive } if err := s.recurringRepo.Update(recurringTransaction); err != nil { return nil, fmt.Errorf("failed to update recurring transaction: %w", err) } return recurringTransaction, nil } // Delete deletes a recurring transaction after verifying ownership func (s *RecurringTransactionService) Delete(userID, id uint) error { recurringTransaction, err := s.recurringRepo.GetByID(userID, id) if err != nil { return err } if recurringTransaction.UserID != userID { return repository.ErrRecurringTransactionNotFound } return s.recurringRepo.Delete(userID, id) } // List retrieves all recurring transactions for a user func (s *RecurringTransactionService) List(userID uint) ([]models.RecurringTransaction, error) { return s.recurringRepo.List(userID) } // GetActive retrieves all active recurring transactions for a user func (s *RecurringTransactionService) GetActive(userID uint) ([]models.RecurringTransaction, error) { return s.recurringRepo.GetActive(userID) } // CalculateNextOccurrence calculates the next occurrence date based on the current date and frequency func (s *RecurringTransactionService) CalculateNextOccurrence(currentDate time.Time, frequency models.FrequencyType) time.Time { switch frequency { case models.FrequencyDaily: return currentDate.AddDate(0, 0, 1) case models.FrequencyWeekly: return currentDate.AddDate(0, 0, 7) case models.FrequencyMonthly: return currentDate.AddDate(0, 1, 0) case models.FrequencyYearly: return currentDate.AddDate(1, 0, 0) default: // Default to daily if unknown frequency return currentDate.AddDate(0, 0, 1) } } // ProcessDueTransactionsResult represents the result of processing due transactions type ProcessDueTransactionsResult struct { Transactions []models.Transaction `json:"transactions"` Allocations []AllocationResult `json:"allocations,omitempty"` } // ProcessDueTransactions processes all due recurring transactions for a user and generates actual transactions // For income transactions, it also triggers matching allocation rules func (s *RecurringTransactionService) ProcessDueTransactions(userID uint, now time.Time) (*ProcessDueTransactionsResult, error) { // Get all due recurring transactions dueRecurringTransactions, err := s.recurringRepo.GetDueTransactions(userID, now) if err != nil { return nil, fmt.Errorf("failed to get due recurring transactions: %w", err) } result := &ProcessDueTransactionsResult{ Transactions: []models.Transaction{}, Allocations: []AllocationResult{}, } for _, recurringTxn := range dueRecurringTransactions { // Check if the recurring transaction has ended if recurringTxn.EndDate != nil && recurringTxn.NextOccurrence.After(*recurringTxn.EndDate) { // Deactivate the recurring transaction recurringTxn.IsActive = false if err := s.recurringRepo.Update(&recurringTxn); err != nil { return nil, fmt.Errorf("failed to deactivate recurring transaction %d: %w", recurringTxn.ID, err) } continue } // Start a database transaction for each recurring transaction tx := s.db.Begin() if tx.Error != nil { return nil, fmt.Errorf("failed to begin transaction: %w", tx.Error) } // Generate the transaction transaction := models.Transaction{ UserID: recurringTxn.UserID, Amount: recurringTxn.Amount, Type: recurringTxn.Type, CategoryID: recurringTxn.CategoryID, AccountID: recurringTxn.AccountID, Currency: recurringTxn.Currency, TransactionDate: recurringTxn.NextOccurrence, Note: recurringTxn.Note, RecurringID: &recurringTxn.ID, } // Create the transaction if err := tx.Create(&transaction).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to create transaction from recurring transaction %d: %w", recurringTxn.ID, err) } // Update account balance var account models.Account if err := tx.First(&account, recurringTxn.AccountID).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to get account %d: %w", recurringTxn.AccountID, err) } switch recurringTxn.Type { case models.TransactionTypeIncome: account.Balance += recurringTxn.Amount case models.TransactionTypeExpense: account.Balance -= recurringTxn.Amount } if err := tx.Save(&account).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update account balance: %w", err) } // For income transactions, check and apply allocation rules if recurringTxn.Type == models.TransactionTypeIncome && s.allocationRuleRepo != nil { allocationResult, err := s.applyAllocationRulesForIncome(userID, tx, recurringTxn.AccountID, recurringTxn.Amount) if err != nil { tx.Rollback() return nil, fmt.Errorf("failed to apply allocation rules: %w", err) } if allocationResult != nil { result.Allocations = append(result.Allocations, *allocationResult) } } // Calculate and update next occurrence nextOccurrence := s.CalculateNextOccurrence(recurringTxn.NextOccurrence, recurringTxn.Frequency) recurringTxn.NextOccurrence = nextOccurrence // Check if the next occurrence is beyond the end date if recurringTxn.EndDate != nil && nextOccurrence.After(*recurringTxn.EndDate) { recurringTxn.IsActive = false } if err := tx.Save(&recurringTxn).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("failed to update recurring transaction %d: %w", recurringTxn.ID, err) } // Commit the transaction if err := tx.Commit().Error; err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } result.Transactions = append(result.Transactions, transaction) } return result, nil } // applyAllocationRulesForIncome applies matching allocation rules for income transactions func (s *RecurringTransactionService) applyAllocationRulesForIncome(userID uint, tx *gorm.DB, accountID uint, amount float64) (*AllocationResult, error) { // Get active allocation rules that match income trigger and source account rules, err := s.allocationRuleRepo.GetActiveByTriggerTypeAndAccount(userID, models.TriggerTypeIncome, accountID) if err != nil { return nil, fmt.Errorf("failed to get allocation rules: %w", err) } if len(rules) == 0 { return nil, nil // No matching rules } // Apply the first matching rule (can be extended to apply multiple rules) rule := rules[0] // Calculate allocations result := &AllocationResult{ RuleID: rule.ID, RuleName: rule.Name, TotalAmount: amount, Allocations: []AllocationDetail{}, } totalAllocated := 0.0 // Process each target for _, target := range rule.Targets { var allocatedAmount float64 // Calculate allocation amount if target.Percentage != nil { allocatedAmount = amount * (*target.Percentage / 100.0) } else if target.FixedAmount != nil { allocatedAmount = *target.FixedAmount // Ensure we don't allocate more than available if allocatedAmount > amount-totalAllocated { allocatedAmount = amount - totalAllocated } } else { continue // Skip invalid target } // Round to 2 decimal places allocatedAmount = float64(int(allocatedAmount*100+0.5)) / 100 if allocatedAmount <= 0 { continue } // Get target name and apply allocation targetName := "" switch target.TargetType { case models.TargetTypeAccount: var targetAccount models.Account if err := tx.First(&targetAccount, target.TargetID).Error; err != nil { return nil, fmt.Errorf("failed to get target account: %w", err) } targetName = targetAccount.Name // Add to target account targetAccount.Balance += allocatedAmount if err := tx.Save(&targetAccount).Error; err != nil { return nil, fmt.Errorf("failed to update target account balance: %w", err) } // Deduct from source account var sourceAccount models.Account if err := tx.First(&sourceAccount, accountID).Error; err != nil { return nil, fmt.Errorf("failed to get source account: %w", err) } sourceAccount.Balance -= allocatedAmount if err := tx.Save(&sourceAccount).Error; err != nil { return nil, fmt.Errorf("failed to update source account balance: %w", err) } case models.TargetTypePiggyBank: var piggyBank models.PiggyBank if err := tx.First(&piggyBank, target.TargetID).Error; err != nil { return nil, fmt.Errorf("failed to get target piggy bank: %w", err) } targetName = piggyBank.Name // Add to piggy bank piggyBank.CurrentAmount += allocatedAmount if err := tx.Save(&piggyBank).Error; err != nil { return nil, fmt.Errorf("failed to update piggy bank balance: %w", err) } // Deduct from source account var sourceAccount models.Account if err := tx.First(&sourceAccount, accountID).Error; err != nil { return nil, fmt.Errorf("failed to get source account: %w", err) } sourceAccount.Balance -= allocatedAmount if err := tx.Save(&sourceAccount).Error; err != nil { return nil, fmt.Errorf("failed to update source account balance: %w", err) } default: continue // Skip invalid target type } // Add to result result.Allocations = append(result.Allocations, AllocationDetail{ TargetType: target.TargetType, TargetID: target.TargetID, TargetName: targetName, Amount: allocatedAmount, Percentage: target.Percentage, FixedAmount: target.FixedAmount, }) totalAllocated += allocatedAmount } result.AllocatedAmount = totalAllocated result.Remaining = amount - totalAllocated // Create allocation record if totalAllocated > 0 { allocationRecord := &models.AllocationRecord{ UserID: userID, RuleID: rule.ID, RuleName: rule.Name, SourceAccountID: accountID, TotalAmount: amount, AllocatedAmount: totalAllocated, RemainingAmount: result.Remaining, Note: fmt.Sprintf("鍛ㄦ湡鎬ф敹鍏ヨ嚜鍔ㄥ垎閰?(瑙勫垯: %s)", rule.Name), } if err := tx.Create(allocationRecord).Error; err != nil { return nil, fmt.Errorf("failed to create allocation record: %w", err) } // Save allocation record details for _, allocation := range result.Allocations { detail := &models.AllocationRecordDetail{ RecordID: allocationRecord.ID, TargetType: allocation.TargetType, TargetID: allocation.TargetID, TargetName: allocation.TargetName, Amount: allocation.Amount, Percentage: allocation.Percentage, FixedAmount: allocation.FixedAmount, } if err := tx.Create(detail).Error; err != nil { return nil, fmt.Errorf("failed to create allocation record detail: %w", err) } } } return result, nil } // GetByAccountID retrieves all recurring transactions for a specific account func (s *RecurringTransactionService) GetByAccountID(userID, accountID uint) ([]models.RecurringTransaction, error) { return s.recurringRepo.GetByAccountID(userID, accountID) } // GetByCategoryID retrieves all recurring transactions for a specific category func (s *RecurringTransactionService) GetByCategoryID(userID, categoryID uint) ([]models.RecurringTransaction, error) { return s.recurringRepo.GetByCategoryID(userID, categoryID) }