package service import ( "errors" "fmt" "math" "time" "accounting-app/internal/models" "accounting-app/internal/repository" "gorm.io/gorm" ) // ErrInterestNotEnabled is returned when interest is not enabled for an account var ErrInterestNotEnabled = errors.New("interest is not enabled for this account") // InterestResult represents the result of an interest calculation type InterestResult struct { AccountID uint `json:"account_id"` AccountName string `json:"account_name"` Balance float64 `json:"balance"` AnnualRate float64 `json:"annual_rate"` DailyInterest float64 `json:"daily_interest"` TransactionID uint `json:"transaction_id"` } // InterestService handles business logic for interest calculations // Feature: financial-core-upgrade // Validates: Requirements 3.1-3.3, 3.7, 17.5 type InterestService struct { repo *repository.AccountRepository transactionRepo *repository.TransactionRepository db *gorm.DB } // NewInterestService creates a new InterestService instance func NewInterestService(repo *repository.AccountRepository, transactionRepo *repository.TransactionRepository, db *gorm.DB) *InterestService { return &InterestService{ repo: repo, transactionRepo: transactionRepo, db: db, } } // CalculateDailyInterest calculates and applies daily interest for a single account // Formula: daily_interest = balance × annual_rate / 365 // Validates: Requirements 3.1 func (s *InterestService) CalculateDailyInterest(userID uint, accountID uint, date time.Time) (*InterestResult, error) { // Get account and verify ownership var account models.Account if err := s.db.Where("id = ? AND user_id = ?", accountID, userID).First(&account).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } // Verify interest is enabled if !account.InterestEnabled { return nil, errors.New("interest is not enabled for this account") } // Skip if balance is zero or negative if account.Balance <= 0 { return &InterestResult{ AccountID: account.ID, AccountName: account.Name, Balance: account.Balance, AnnualRate: 0, DailyInterest: 0, }, nil } // Get annual rate annualRate := 0.0 if account.AnnualRate != nil { annualRate = *account.AnnualRate } // Skip if no annual rate if annualRate <= 0 { return &InterestResult{ AccountID: account.ID, AccountName: account.Name, Balance: account.Balance, AnnualRate: annualRate, DailyInterest: 0, }, nil } // Check if interest already calculated for this date (idempotency) calculated, err := s.IsInterestCalculated(accountID, date) if err != nil { return nil, err } if calculated { return nil, errors.New("interest already calculated for this date") } // Calculate daily interest: balance × annual_rate / 365 dailyInterest := roundToTwoDecimals(account.Balance * annualRate / 365) // Skip if interest is too small if dailyInterest < 0.01 { return &InterestResult{ AccountID: account.ID, AccountName: account.Name, Balance: account.Balance, AnnualRate: annualRate, DailyInterest: 0, }, nil } var result InterestResult // Create interest transaction and update balance err = s.db.Transaction(func(tx *gorm.DB) error { // Create interest transaction subType := models.TransactionSubTypeInterest transaction := &models.Transaction{ Amount: dailyInterest, Type: models.TransactionTypeIncome, CategoryID: 1, // Default category, should be configured for interest AccountID: accountID, Currency: account.Currency, TransactionDate: date, SubType: &subType, Note: fmt.Sprintf("利息收入 - %s (年化%.2f%%)", account.Name, annualRate*100), } if err := tx.Create(transaction).Error; err != nil { return fmt.Errorf("failed to create interest transaction: %w", err) } // Update account balance account.Balance += dailyInterest if err := tx.Save(&account).Error; err != nil { return fmt.Errorf("failed to update account balance: %w", err) } result = InterestResult{ AccountID: account.ID, AccountName: account.Name, Balance: account.Balance, AnnualRate: annualRate, DailyInterest: dailyInterest, TransactionID: transaction.ID, } return nil }) if err != nil { return nil, err } return &result, nil } // CalculateAllInterest calculates interest for all enabled accounts for a specific user func (s *InterestService) CalculateAllInterest(userID uint, date time.Time) ([]InterestResult, error) { // Get all accounts with interest enabled for this user var accounts []models.Account err := s.db.Where("user_id = ? AND interest_enabled = ?", userID, true).Find(&accounts).Error if err != nil { return nil, fmt.Errorf("failed to get interest-enabled accounts: %w", err) } var results []InterestResult for _, account := range accounts { result, err := s.CalculateDailyInterest(userID, account.ID, date) if err != nil { // Log error but continue with other accounts fmt.Printf("Error calculating interest for account %d: %v\n", account.ID, err) continue } if result != nil && result.DailyInterest > 0 { results = append(results, *result) } } return results, nil } // AddManualInterest creates a manual interest entry func (s *InterestService) AddManualInterest(userID uint, accountID uint, amount float64, date time.Time, note string) (*models.Transaction, error) { if amount <= 0 { return nil, errors.New("interest amount must be positive") } // Get account and verify ownership var account models.Account if err := s.db.Where("id = ? AND user_id = ?", accountID, userID).First(&account).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrAccountNotFound } return nil, fmt.Errorf("failed to get account: %w", err) } var transaction *models.Transaction err := s.db.Transaction(func(tx *gorm.DB) error { // Create interest transaction subType := models.TransactionSubTypeInterest noteText := note if noteText == "" { noteText = fmt.Sprintf("手动利息收入 - %s", account.Name) } transaction = &models.Transaction{ Amount: amount, Type: models.TransactionTypeIncome, CategoryID: 1, // Default category AccountID: accountID, Currency: account.Currency, TransactionDate: date, SubType: &subType, Note: noteText, } if err := tx.Create(transaction).Error; err != nil { return fmt.Errorf("failed to create interest transaction: %w", err) } // Update account balance account.Balance += amount if err := tx.Save(&account).Error; err != nil { return fmt.Errorf("failed to update account balance: %w", err) } return nil }) if err != nil { return nil, err } return transaction, nil } // IsInterestCalculated checks if interest has already been calculated for a specific date // Validates: Requirements 17.5 (idempotency) func (s *InterestService) IsInterestCalculated(accountID uint, date time.Time) (bool, error) { subType := models.TransactionSubTypeInterest startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) endOfDay := startOfDay.Add(24 * time.Hour) var count int64 err := s.db.Model(&models.Transaction{}). Where("account_id = ? AND sub_type = ? AND transaction_date >= ? AND transaction_date < ?", accountID, subType, startOfDay, endOfDay). Count(&count).Error if err != nil { return false, fmt.Errorf("failed to check interest calculation: %w", err) } return count > 0, nil } // GetInterestEnabledAccounts retrieves all accounts with interest enabled func (s *InterestService) GetInterestEnabledAccounts() ([]models.Account, error) { var accounts []models.Account err := s.db.Where("interest_enabled = ?", true).Find(&accounts).Error if err != nil { return nil, fmt.Errorf("failed to get interest-enabled accounts: %w", err) } return accounts, nil } // roundToTwoDecimals rounds a float64 to two decimal places func roundToTwoDecimals(value float64) float64 { return math.Round(value*100) / 100 }