272 lines
8.1 KiB
Go
272 lines
8.1 KiB
Go
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
|
||
}
|