aboutsummaryrefslogtreecommitdiffstats
path: root/backend/internal/core/goal_service.go
diff options
context:
space:
mode:
authorLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-27 23:02:42 +0530
committerLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-27 23:02:42 +0530
commit538d933baef56d7ee76f78617b553d63713efa24 (patch)
tree3fcbc4208849dfa0e5dc8fe5761e103a3591c283 /backend/internal/core/goal_service.go
parent3941d80ff120238b973451325b834ebd8377281e (diff)
downloadfinance-538d933baef56d7ee76f78617b553d63713efa24.tar.gz
finance-538d933baef56d7ee76f78617b553d63713efa24.tar.bz2
finance-538d933baef56d7ee76f78617b553d63713efa24.zip
finance: feat: added the goal page with some improvements of ui
Diffstat (limited to 'backend/internal/core/goal_service.go')
-rw-r--r--backend/internal/core/goal_service.go196
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
+}