1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
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
}
|