Files
Novault-backend/internal/service/interest_service.go
2026-01-25 21:59:00 +08:00

272 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}