This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
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
}