338 lines
11 KiB
Go
338 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// InterestScheduler handles scheduled calculation of daily interest for all enabled accounts
|
|
// Feature: financial-core-upgrade
|
|
// Validates: Requirements 17.1-17.6
|
|
type InterestScheduler struct {
|
|
interestService *InterestService
|
|
executionTime time.Duration // Time of day to execute (e.g., 5 minutes after midnight)
|
|
stopChan chan struct{}
|
|
mu sync.Mutex
|
|
running bool
|
|
lastExecution time.Time
|
|
}
|
|
|
|
// InterestSchedulerConfig holds configuration for the interest scheduler
|
|
type InterestSchedulerConfig struct {
|
|
// ExecutionHour is the hour of day to run (0-23), default 0
|
|
ExecutionHour int
|
|
// ExecutionMinute is the minute of hour to run (0-59), default 5
|
|
ExecutionMinute int
|
|
}
|
|
|
|
// DefaultInterestSchedulerConfig returns the default configuration
|
|
// Default execution time: 00:05 (5 minutes after midnight)
|
|
// Validates: Requirements 17.1
|
|
func DefaultInterestSchedulerConfig() InterestSchedulerConfig {
|
|
return InterestSchedulerConfig{
|
|
ExecutionHour: 0,
|
|
ExecutionMinute: 5,
|
|
}
|
|
}
|
|
|
|
// NewInterestScheduler creates a new InterestScheduler instance
|
|
// Validates: Requirements 17.1
|
|
func NewInterestScheduler(interestService *InterestService, config InterestSchedulerConfig) *InterestScheduler {
|
|
// Calculate execution time as duration from midnight
|
|
executionTime := time.Duration(config.ExecutionHour)*time.Hour + time.Duration(config.ExecutionMinute)*time.Minute
|
|
|
|
return &InterestScheduler{
|
|
interestService: interestService,
|
|
executionTime: executionTime,
|
|
stopChan: make(chan struct{}),
|
|
running: false,
|
|
}
|
|
}
|
|
|
|
// Start begins the scheduled interest calculation
|
|
// It checks for missed calculations on startup, then runs daily at the configured time
|
|
// This method blocks until Stop() is called or context is cancelled
|
|
// Validates: Requirements 17.1, 17.6
|
|
func (s *InterestScheduler) Start(ctx context.Context) {
|
|
s.mu.Lock()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
log.Println("[InterestScheduler] Scheduler is already running")
|
|
return
|
|
}
|
|
s.running = true
|
|
s.stopChan = make(chan struct{}) // Reset stop channel
|
|
s.mu.Unlock()
|
|
|
|
log.Printf("[InterestScheduler] Starting interest scheduler, execution time: %02d:%02d",
|
|
int(s.executionTime.Hours()), int(s.executionTime.Minutes())%60)
|
|
|
|
// Check for missed calculations on startup (Requirement 17.6)
|
|
s.checkMissedCalculations(ctx)
|
|
|
|
// Calculate time until next execution
|
|
nextExecution := s.calculateNextExecution()
|
|
log.Printf("[InterestScheduler] Next scheduled execution: %s", nextExecution.Format("2006-01-02 15:04:05"))
|
|
|
|
timer := time.NewTimer(time.Until(nextExecution))
|
|
defer timer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-timer.C:
|
|
s.executeInterestCalculation(ctx)
|
|
// Reset timer for next day
|
|
nextExecution = s.calculateNextExecution()
|
|
log.Printf("[InterestScheduler] Next scheduled execution: %s", nextExecution.Format("2006-01-02 15:04:05"))
|
|
timer.Reset(time.Until(nextExecution))
|
|
|
|
case <-s.stopChan:
|
|
log.Println("[InterestScheduler] Scheduler stopped by Stop() call")
|
|
s.mu.Lock()
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
return
|
|
|
|
case <-ctx.Done():
|
|
log.Println("[InterestScheduler] Scheduler stopped due to context cancellation")
|
|
s.mu.Lock()
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop gracefully stops the scheduler
|
|
func (s *InterestScheduler) Stop() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if !s.running {
|
|
log.Println("[InterestScheduler] Scheduler is not running")
|
|
return
|
|
}
|
|
|
|
log.Println("[InterestScheduler] Stopping scheduler...")
|
|
close(s.stopChan)
|
|
}
|
|
|
|
// IsRunning returns whether the scheduler is currently running
|
|
func (s *InterestScheduler) IsRunning() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.running
|
|
}
|
|
|
|
// GetLastExecution returns the time of the last execution
|
|
func (s *InterestScheduler) GetLastExecution() time.Time {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.lastExecution
|
|
}
|
|
|
|
// calculateNextExecution calculates the next execution time
|
|
func (s *InterestScheduler) calculateNextExecution() time.Time {
|
|
now := time.Now()
|
|
|
|
// Calculate today's execution time
|
|
todayExecution := time.Date(
|
|
now.Year(), now.Month(), now.Day(),
|
|
int(s.executionTime.Hours()),
|
|
int(s.executionTime.Minutes())%60,
|
|
0, 0, now.Location(),
|
|
)
|
|
|
|
// If today's execution time has passed, schedule for tomorrow
|
|
if now.After(todayExecution) {
|
|
return todayExecution.Add(24 * time.Hour)
|
|
}
|
|
|
|
return todayExecution
|
|
}
|
|
|
|
// checkMissedCalculations checks for and processes any missed interest calculations
|
|
// This is called on startup to ensure no interest calculations are missed
|
|
// Validates: Requirements 17.6
|
|
func (s *InterestScheduler) checkMissedCalculations(ctx context.Context) {
|
|
log.Println("[InterestScheduler] Checking for missed interest calculations...")
|
|
|
|
// Get all interest-enabled accounts
|
|
accounts, err := s.interestService.GetInterestEnabledAccounts()
|
|
if err != nil {
|
|
log.Printf("[InterestScheduler] Error getting interest-enabled accounts: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(accounts) == 0 {
|
|
log.Println("[InterestScheduler] No interest-enabled accounts found")
|
|
return
|
|
}
|
|
|
|
// Check yesterday's date (the most recent date that should have been calculated)
|
|
yesterday := time.Now().AddDate(0, 0, -1)
|
|
yesterday = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
|
|
|
|
missedCount := 0
|
|
processedCount := 0
|
|
|
|
for _, account := range accounts {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("[InterestScheduler] Missed calculation check cancelled")
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Check if interest was calculated for yesterday
|
|
calculated, err := s.interestService.IsInterestCalculated(account.ID, yesterday)
|
|
if err != nil {
|
|
log.Printf("[InterestScheduler] Error checking interest calculation for account %d: %v", account.ID, err)
|
|
continue
|
|
}
|
|
|
|
if !calculated {
|
|
missedCount++
|
|
log.Printf("[InterestScheduler] Processing missed interest calculation for account %d (%s) for date %s",
|
|
account.ID, account.Name, yesterday.Format("2006-01-02"))
|
|
|
|
result, err := s.interestService.CalculateDailyInterest(account.UserID, account.ID, yesterday)
|
|
if err != nil {
|
|
log.Printf("[InterestScheduler] Error calculating missed interest for account %d: %v", account.ID, err)
|
|
continue
|
|
}
|
|
|
|
if result != nil && result.DailyInterest > 0 {
|
|
processedCount++
|
|
log.Printf("[InterestScheduler] Processed missed interest for account %d: %.2f", account.ID, result.DailyInterest)
|
|
}
|
|
}
|
|
}
|
|
|
|
if missedCount > 0 {
|
|
log.Printf("[InterestScheduler] Missed calculation check complete: %d missed, %d processed", missedCount, processedCount)
|
|
} else {
|
|
log.Println("[InterestScheduler] No missed calculations found")
|
|
}
|
|
}
|
|
|
|
// executeInterestCalculation executes the daily interest calculation for all enabled accounts
|
|
// Validates: Requirements 17.2, 17.3, 17.4
|
|
func (s *InterestScheduler) executeInterestCalculation(ctx context.Context) {
|
|
startTime := time.Now()
|
|
log.Printf("[InterestScheduler] Starting daily interest calculation at %s", startTime.Format("2006-01-02 15:04:05"))
|
|
|
|
// Use today's date for calculation
|
|
calculationDate := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, startTime.Location())
|
|
|
|
// Get all interest-enabled accounts
|
|
accounts, err := s.interestService.GetInterestEnabledAccounts()
|
|
if err != nil {
|
|
log.Printf("[InterestScheduler] Error getting interest-enabled accounts: %v", err)
|
|
return
|
|
}
|
|
|
|
totalAccounts := len(accounts)
|
|
successCount := 0
|
|
skipCount := 0
|
|
errorCount := 0
|
|
totalInterest := 0.0
|
|
|
|
log.Printf("[InterestScheduler] Processing %d interest-enabled accounts", totalAccounts)
|
|
|
|
// Process each account independently (Requirement 17.4)
|
|
for _, account := range accounts {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("[InterestScheduler] Interest calculation cancelled")
|
|
return
|
|
default:
|
|
}
|
|
|
|
result, err := s.interestService.CalculateDailyInterest(account.UserID, account.ID, calculationDate)
|
|
if err != nil {
|
|
// Log error but continue with other accounts (Requirement 17.4)
|
|
errorCount++
|
|
log.Printf("[InterestScheduler] Error calculating interest for account %d (%s): %v",
|
|
account.ID, account.Name, err)
|
|
continue
|
|
}
|
|
|
|
if result == nil {
|
|
skipCount++
|
|
continue
|
|
}
|
|
|
|
if result.DailyInterest > 0 {
|
|
successCount++
|
|
totalInterest += result.DailyInterest
|
|
log.Printf("[InterestScheduler] Account %d (%s): balance=%.2f, rate=%.4f, interest=%.2f",
|
|
result.AccountID, result.AccountName, result.Balance, result.AnnualRate, result.DailyInterest)
|
|
} else {
|
|
skipCount++
|
|
}
|
|
}
|
|
|
|
// Update last execution time
|
|
s.mu.Lock()
|
|
s.lastExecution = startTime
|
|
s.mu.Unlock()
|
|
|
|
// Log execution summary (Requirement 17.3)
|
|
endTime := time.Now()
|
|
duration := endTime.Sub(startTime)
|
|
log.Printf("[InterestScheduler] Daily interest calculation completed:")
|
|
log.Printf("[InterestScheduler] Start time: %s", startTime.Format("2006-01-02 15:04:05"))
|
|
log.Printf("[InterestScheduler] End time: %s", endTime.Format("2006-01-02 15:04:05"))
|
|
log.Printf("[InterestScheduler] Duration: %v", duration)
|
|
log.Printf("[InterestScheduler] Total accounts: %d", totalAccounts)
|
|
log.Printf("[InterestScheduler] Successful: %d", successCount)
|
|
log.Printf("[InterestScheduler] Skipped: %d", skipCount)
|
|
log.Printf("[InterestScheduler] Errors: %d", errorCount)
|
|
log.Printf("[InterestScheduler] Total interest: %.2f", totalInterest)
|
|
}
|
|
|
|
// ForceCalculation triggers an immediate interest calculation for a specific user outside of the regular schedule
|
|
// This can be used for manual trigger or testing
|
|
func (s *InterestScheduler) ForceCalculation(ctx context.Context, userID uint) (*InterestCalculationSummary, error) {
|
|
log.Printf("[InterestScheduler] Force calculation triggered for user %d", userID)
|
|
|
|
startTime := time.Now()
|
|
calculationDate := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, startTime.Location())
|
|
|
|
results, err := s.interestService.CalculateAllInterest(userID, calculationDate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to calculate interest: %w", err)
|
|
}
|
|
|
|
endTime := time.Now()
|
|
|
|
summary := &InterestCalculationSummary{
|
|
StartTime: startTime,
|
|
EndTime: endTime,
|
|
Duration: endTime.Sub(startTime),
|
|
AccountsCount: len(results),
|
|
Results: results,
|
|
}
|
|
|
|
// Calculate total interest
|
|
for _, r := range results {
|
|
summary.TotalInterest += r.DailyInterest
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// InterestCalculationSummary represents the summary of an interest calculation run
|
|
type InterestCalculationSummary struct {
|
|
StartTime time.Time `json:"start_time"`
|
|
EndTime time.Time `json:"end_time"`
|
|
Duration time.Duration `json:"duration"`
|
|
AccountsCount int `json:"accounts_count"`
|
|
TotalInterest float64 `json:"total_interest"`
|
|
Results []InterestResult `json:"results"`
|
|
}
|