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:") 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 }