init
This commit is contained in:
271
internal/service/interest_service.go
Normal file
271
internal/service/interest_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user