diff options
Diffstat (limited to 'backend/internal/core/goal_service.go')
-rw-r--r-- | backend/internal/core/goal_service.go | 196 |
1 files changed, 196 insertions, 0 deletions
diff --git a/backend/internal/core/goal_service.go b/backend/internal/core/goal_service.go new file mode 100644 index 0000000..60cffd5 --- /dev/null +++ b/backend/internal/core/goal_service.go @@ -0,0 +1,196 @@ +package core + +import ( + "finance/backend/internal/database" + "finance/backend/internal/models" + "fmt" + "log" + "time" + + "gorm.io/gorm" +) + +// GoalService handles business logic for financial goals +type GoalService struct { + db *gorm.DB +} + +// NewGoalService creates and returns a new GoalService +func NewGoalService() *GoalService { + return &GoalService{ + db: database.DB, + } +} + +// GoalProgress represents calculated progress data for a goal +type GoalProgress struct { + Goal models.Goal `json:"goal"` + PercentComplete float64 `json:"percentComplete"` + AmountRemaining int64 `json:"amountRemaining"` + DaysRemaining int `json:"daysRemaining,omitempty"` + RequiredPerDay int64 `json:"requiredPerDay,omitempty"` + RequiredPerMonth int64 `json:"requiredPerMonth,omitempty"` + OnTrack bool `json:"onTrack"` +} + +// GetGoalProgress retrieves a single goal with enhanced progress tracking data +func (s *GoalService) GetGoalProgress(userID uint, goalID uint) (*GoalProgress, error) { + var goal models.Goal + if err := s.db.Where("id = ? AND user_id = ?", goalID, userID).First(&goal).Error; err != nil { + return nil, err + } + + // Calculate progress + return s.calculateGoalProgress(&goal) +} + +// GetAllGoalsProgress retrieves all goals for a user with enhanced progress data +func (s *GoalService) GetAllGoalsProgress(userID uint, status string) ([]*GoalProgress, error) { + var goals []models.Goal + + query := s.db.Where("user_id = ?", userID) + if status != "" { + query = query.Where("status = ?", status) + } + + if err := query.Find(&goals).Error; err != nil { + return nil, err + } + + progress := make([]*GoalProgress, 0, len(goals)) + for i := range goals { + goalProgress, err := s.calculateGoalProgress(&goals[i]) + if err != nil { + log.Printf("Error calculating progress for goal %d: %v", goals[i].ID, err) + continue + } + progress = append(progress, goalProgress) + } + + return progress, nil +} + +// calculateGoalProgress computes additional progress metrics for a goal +func (s *GoalService) calculateGoalProgress(goal *models.Goal) (*GoalProgress, error) { + progress := &GoalProgress{ + Goal: *goal, + AmountRemaining: goal.TargetAmount - goal.CurrentAmount, + } + + // Calculate percentage complete (avoid division by zero) + if goal.TargetAmount > 0 { + progress.PercentComplete = float64(goal.CurrentAmount) / float64(goal.TargetAmount) * 100 + } + + // Calculate time-based metrics if a target date exists + if !goal.TargetDate.IsZero() { + now := time.Now() + + // Only calculate days remaining if target date is in the future + if goal.TargetDate.After(now) { + daysRemaining := int(goal.TargetDate.Sub(now).Hours() / 24) + progress.DaysRemaining = daysRemaining + + // Calculate required savings per day/month + if progress.AmountRemaining > 0 && daysRemaining > 0 { + progress.RequiredPerDay = progress.AmountRemaining / int64(daysRemaining) + progress.RequiredPerMonth = progress.AmountRemaining / int64((daysRemaining+30-1)/30) // Ceiling division to months + } + + // Calculate if on track based on time elapsed vs progress made + totalDuration := goal.TargetDate.Sub(goal.CreatedAt) + elapsedDuration := now.Sub(goal.CreatedAt) + + if totalDuration > 0 { + expectedProgress := float64(elapsedDuration) / float64(totalDuration) + actualProgress := float64(goal.CurrentAmount) / float64(goal.TargetAmount) + + // Consider on track if actual progress is at least 90% of expected progress + progress.OnTrack = actualProgress >= (expectedProgress * 0.9) + } + } else { + // Target date passed + progress.DaysRemaining = 0 + progress.OnTrack = goal.CurrentAmount >= goal.TargetAmount + } + } else { + // No target date, so consider on track if any progress is made + progress.OnTrack = goal.CurrentAmount > 0 + } + + return progress, nil +} + +// UpdateGoalFromTransactions updates goal progress based on recent transactions +// This can be run periodically or when triggered by transaction changes +func (s *GoalService) UpdateGoalFromTransactions(goalID uint) error { + var goal models.Goal + if err := s.db.First(&goal, goalID).Error; err != nil { + return err + } + + // Get all related savings transactions for this goal + // This assumes a category field in transactions that indicates + // they are related to this goal (format: "Goal:<goalID>") + var transactions []models.Transaction + if err := s.db.Where("user_id = ? AND category = ?", + goal.UserID, fmt.Sprintf("Goal:%d", goalID)).Find(&transactions).Error; err != nil { + return err + } + + // Calculate sum of all savings transactions + var totalSaved int64 + for _, tx := range transactions { + if tx.Type == "Income" || tx.Type == "Savings" { + totalSaved += tx.Amount + } else if tx.Type == "Expense" { + totalSaved -= tx.Amount + } + } + + // Update goal progress + goal.CurrentAmount = totalSaved + + // Check if goal has been achieved + if goal.CurrentAmount >= goal.TargetAmount { + goal.Status = "Achieved" + } + + return s.db.Save(&goal).Error +} + +// LinkTransactionToGoal tags a transaction as contributing to a specific goal +func (s *GoalService) LinkTransactionToGoal(txID uint, goalID uint) error { + var transaction models.Transaction + if err := s.db.First(&transaction, txID).Error; err != nil { + return err + } + + // Set the category to indicate this transaction is for the goal + transaction.Category = fmt.Sprintf("Goal:%d", goalID) + + // Save the updated transaction + if err := s.db.Save(&transaction).Error; err != nil { + return err + } + + // Update the goal progress based on this and other transactions + return s.UpdateGoalFromTransactions(goalID) +} + +// RecalculateAllGoals updates the progress of all active goals +// This could be run daily as a background task +func (s *GoalService) RecalculateAllGoals() error { + var goals []models.Goal + if err := s.db.Where("status = ?", "Active").Find(&goals).Error; err != nil { + return err + } + + for _, goal := range goals { + if err := s.UpdateGoalFromTransactions(goal.ID); err != nil { + log.Printf("Error updating goal %d: %v", goal.ID, err) + } + } + + return nil +} |