aboutsummaryrefslogtreecommitdiffstats
path: root/backend/internal
diff options
context:
space:
mode:
Diffstat (limited to 'backend/internal')
-rw-r--r--backend/internal/api/handlers/goal_handler.go128
-rw-r--r--backend/internal/api/v1/goals/goals.go26
-rw-r--r--backend/internal/core/goal_service.go196
-rw-r--r--backend/internal/core/goal_service_test.go129
-rw-r--r--backend/internal/router/router.go6
5 files changed, 483 insertions, 2 deletions
diff --git a/backend/internal/api/handlers/goal_handler.go b/backend/internal/api/handlers/goal_handler.go
index 09bea74..efbe7f9 100644
--- a/backend/internal/api/handlers/goal_handler.go
+++ b/backend/internal/api/handlers/goal_handler.go
@@ -6,6 +6,7 @@ import (
"strconv"
"time"
+ "finance/backend/internal/core"
"finance/backend/internal/database"
"finance/backend/internal/models"
@@ -35,13 +36,21 @@ type UpdateGoalProgressInput struct {
CurrentAmount int64 `json:"currentAmount" binding:"required"`
}
+// LinkTransactionInput defines the structure for linking a transaction to a goal
+type LinkTransactionInput struct {
+ TransactionID uint `json:"transactionId" binding:"required"`
+}
+
// GoalHandler handles all goal-related operations in the API
type GoalHandler struct {
+ goalService *core.GoalService
}
// NewGoalHandler creates and returns a new GoalHandler instance
func NewGoalHandler() *GoalHandler {
- return &GoalHandler{}
+ return &GoalHandler{
+ goalService: core.NewGoalService(),
+ }
}
// GetGoals retrieves all goals for the authenticated user
@@ -270,3 +279,120 @@ func (h *GoalHandler) DeleteGoal(c *gin.Context) {
log.Printf("Goal ID %d deleted successfully for user %d", goalID, userID)
c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"})
}
+
+// GetGoalProgressDetails retrieves a goal with detailed progress information
+func (h *GoalHandler) GetGoalProgressDetails(c *gin.Context) {
+ userID := c.MustGet("userID").(uint)
+ goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ log.Printf("Error parsing goal ID: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
+ return
+ }
+
+ progress, err := h.goalService.GetGoalProgress(userID, uint(goalID))
+ if err != nil {
+ log.Printf("Error fetching goal progress for ID %d, user %d: %v", goalID, userID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, progress)
+}
+
+// GetAllGoalsProgressDetails retrieves all goals with enhanced progress details
+func (h *GoalHandler) GetAllGoalsProgressDetails(c *gin.Context) {
+ userID := c.MustGet("userID").(uint)
+
+ // Filter by status if provided
+ status := c.Query("status")
+
+ progress, err := h.goalService.GetAllGoalsProgress(userID, status)
+ if err != nil {
+ log.Printf("Error fetching all goals progress for user %d: %v", userID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get goal progress"})
+ return
+ }
+
+ c.JSON(http.StatusOK, progress)
+}
+
+// LinkTransactionToGoal links a transaction to a specific goal
+func (h *GoalHandler) LinkTransactionToGoal(c *gin.Context) {
+ userID := c.MustGet("userID").(uint)
+ goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ log.Printf("Error parsing goal ID: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
+ return
+ }
+
+ var input LinkTransactionInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ log.Printf("Error binding JSON for linking transaction: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Verify the goal belongs to the user
+ var goal models.Goal
+ if err := database.DB.Where("id = ? AND user_id = ?", goalID, userID).First(&goal).Error; err != nil {
+ log.Printf("Error fetching goal ID %d for user %d: %v", goalID, userID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
+ return
+ }
+
+ // Verify the transaction belongs to the user
+ var transaction models.Transaction
+ if err := database.DB.Where("id = ? AND user_id = ?", input.TransactionID, userID).First(&transaction).Error; err != nil {
+ log.Printf("Error fetching transaction ID %d for user %d: %v", input.TransactionID, userID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"})
+ return
+ }
+
+ // Link the transaction to the goal
+ if err := h.goalService.LinkTransactionToGoal(input.TransactionID, uint(goalID)); err != nil {
+ log.Printf("Error linking transaction %d to goal %d: %v", input.TransactionID, goalID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to link transaction to goal"})
+ return
+ }
+
+ log.Printf("Transaction ID %d successfully linked to goal ID %d for user %d", input.TransactionID, goalID, userID)
+ c.JSON(http.StatusOK, gin.H{"message": "Transaction linked to goal successfully"})
+}
+
+// RecalculateGoalProgress recalculates a goal's progress based on linked transactions
+func (h *GoalHandler) RecalculateGoalProgress(c *gin.Context) {
+ userID := c.MustGet("userID").(uint)
+ goalID, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ log.Printf("Error parsing goal ID: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"})
+ return
+ }
+
+ // Verify the goal belongs to the user
+ var goal models.Goal
+ if err := database.DB.Where("id = ? AND user_id = ?", goalID, userID).First(&goal).Error; err != nil {
+ log.Printf("Error fetching goal ID %d for user %d: %v", goalID, userID, err)
+ c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"})
+ return
+ }
+
+ // Recalculate the goal progress
+ if err := h.goalService.UpdateGoalFromTransactions(uint(goalID)); err != nil {
+ log.Printf("Error recalculating goal progress for ID %d: %v", goalID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to recalculate goal progress"})
+ return
+ }
+
+ // Fetch the updated goal to return in response
+ if err := database.DB.First(&goal, goalID).Error; err != nil {
+ log.Printf("Error fetching updated goal after recalculation: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch updated goal"})
+ return
+ }
+
+ log.Printf("Goal ID %d progress recalculated successfully for user %d", goalID, userID)
+ c.JSON(http.StatusOK, goal)
+}
diff --git a/backend/internal/api/v1/goals/goals.go b/backend/internal/api/v1/goals/goals.go
index 1d2cd6f..4f99468 100644
--- a/backend/internal/api/v1/goals/goals.go
+++ b/backend/internal/api/v1/goals/goals.go
@@ -1,7 +1,7 @@
package goals
import (
- "finance/backend/handlers"
+ "finance/backend/internal/api/handlers"
"github.com/gin-gonic/gin"
)
@@ -41,3 +41,27 @@ func UpdateGoalProgress() gin.HandlerFunc {
handler := handlers.NewGoalHandler()
return handler.UpdateGoalProgress
}
+
+// GetGoalProgressDetails returns goal with enhanced progress tracking details
+func GetGoalProgressDetails() gin.HandlerFunc {
+ handler := handlers.NewGoalHandler()
+ return handler.GetGoalProgressDetails
+}
+
+// GetAllGoalsProgressDetails returns all goals with enhanced progress tracking details
+func GetAllGoalsProgressDetails() gin.HandlerFunc {
+ handler := handlers.NewGoalHandler()
+ return handler.GetAllGoalsProgressDetails
+}
+
+// LinkTransactionToGoal links a transaction to a goal for progress tracking
+func LinkTransactionToGoal() gin.HandlerFunc {
+ handler := handlers.NewGoalHandler()
+ return handler.LinkTransactionToGoal
+}
+
+// RecalculateGoalProgress recalculates the progress of a goal based on its transactions
+func RecalculateGoalProgress() gin.HandlerFunc {
+ handler := handlers.NewGoalHandler()
+ return handler.RecalculateGoalProgress
+}
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
+}
diff --git a/backend/internal/core/goal_service_test.go b/backend/internal/core/goal_service_test.go
new file mode 100644
index 0000000..2a4446a
--- /dev/null
+++ b/backend/internal/core/goal_service_test.go
@@ -0,0 +1,129 @@
+package core
+
+import (
+ "finance/backend/internal/models"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "gorm.io/gorm"
+)
+
+// Mock goal for testing
+func createTestGoal() *models.Goal {
+ return &models.Goal{
+ Model: gorm.Model{ID: 1, CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}, // created 30 days ago
+ UserID: 1,
+ Name: "Test Goal",
+ TargetAmount: 10000,
+ CurrentAmount: 3000,
+ Status: "Active",
+ TargetDate: time.Now().Add(60 * 24 * time.Hour), // due in 60 days
+ }
+}
+
+// TestCalculateGoalProgress tests the goal progress calculation logic
+func TestCalculateGoalProgress(t *testing.T) {
+ // Create a test service
+ service := &GoalService{}
+
+ // Test with a goal that's on track
+ goal := createTestGoal()
+ progress, err := service.calculateGoalProgress(goal)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, progress)
+
+ // Verify calculations
+ assert.Equal(t, int64(7000), progress.AmountRemaining)
+ assert.InDelta(t, 30.0, progress.PercentComplete, 0.1)
+
+ // Should have around 60 days remaining (might vary slightly based on test execution time)
+ assert.True(t, progress.DaysRemaining > 55 && progress.DaysRemaining <= 61)
+
+ // Test required amounts
+ assert.True(t, progress.RequiredPerDay > 0)
+ assert.True(t, progress.RequiredPerMonth > 0)
+
+ // Verify on track status - goal is at 30% completion, we're 1/3 through the time period
+ // so it should be on track
+ assert.True(t, progress.OnTrack)
+
+ // Test with a goal that's behind
+ goal.CurrentAmount = 1000 // only 10% complete after 1/3 of the time
+ progress, err = service.calculateGoalProgress(goal)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, progress)
+ assert.False(t, progress.OnTrack)
+
+ // Test with a goal that has no target date
+ goal = createTestGoal()
+ goal.TargetDate = time.Time{} // zero time
+ progress, err = service.calculateGoalProgress(goal)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, progress)
+ assert.True(t, progress.OnTrack) // should be on track if any progress made
+ assert.Equal(t, 0, progress.DaysRemaining)
+
+ // Test with a goal whose target date has passed
+ goal = createTestGoal()
+ goal.TargetDate = time.Now().Add(-10 * 24 * time.Hour) // 10 days ago
+ progress, err = service.calculateGoalProgress(goal)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, progress)
+ assert.Equal(t, 0, progress.DaysRemaining)
+
+ // Not on track if target amount not reached
+ assert.False(t, progress.OnTrack)
+
+ // But should be on track if target amount reached despite date passed
+ goal.CurrentAmount = goal.TargetAmount
+ progress, err = service.calculateGoalProgress(goal)
+
+ assert.NoError(t, err)
+ assert.True(t, progress.OnTrack)
+}
+
+// TestUpdateGoalFromTransactions tests updating a goal based on transactions
+func TestUpdateGoalFromTransactions(t *testing.T) {
+ // This test would need a mock database to be fully implemented
+ // Here's a placeholder for when DB mocking is available
+
+ /*
+ // Setup a mock DB
+ db, mock := setupMockDB(t)
+ service := &GoalService{db: db}
+
+ // Setup expectations
+ goalID := uint(1)
+ userID := uint(1)
+
+ // Expect a query to fetch the goal
+ mock.ExpectQuery(`SELECT * FROM "goals" WHERE "id" = ?`).
+ WithArgs(goalID).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "name", "target_amount", "current_amount", "status"}).
+ AddRow(goalID, userID, "Test Goal", 10000, 0, "Active"))
+
+ // Expect a query to fetch transactions
+ mock.ExpectQuery(`SELECT * FROM "transactions" WHERE "user_id" = ? AND "category" = ?`).
+ WithArgs(userID, "Goal:1").
+ WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "type", "category"}).
+ AddRow(1, userID, 500, "Income", "Goal:1").
+ AddRow(2, userID, 300, "Income", "Goal:1").
+ AddRow(3, userID, 200, "Expense", "Goal:1"))
+
+ // Expect an update to the goal
+ mock.ExpectBegin()
+ mock.ExpectExec(`UPDATE "goals" SET`).
+ WithArgs(600, "Active", goalID). // 500 + 300 - 200 = 600
+ WillReturnResult(sqlmock.NewResult(1, 1))
+ mock.ExpectCommit()
+
+ // Run the test
+ err := service.UpdateGoalFromTransactions(goalID)
+ assert.NoError(t, err)
+ */
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 3a4d413..d6e05a5 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -110,6 +110,12 @@ func SetupRouter(cfg *config.Config) *gin.Engine {
protected.DELETE("/goals/:id", goalHandler.DeleteGoal)
protected.PATCH("/goals/:id/progress", goalHandler.UpdateGoalProgress)
+ // New Goal Progress Tracking routes
+ protected.GET("/goals/:id/progress", goalHandler.GetGoalProgressDetails)
+ protected.GET("/goals/progress/all", goalHandler.GetAllGoalsProgressDetails)
+ protected.POST("/goals/:id/link-transaction", goalHandler.LinkTransactionToGoal)
+ protected.POST("/goals/:id/recalculate", goalHandler.RecalculateGoalProgress)
+
// Loan routes
protected.GET("/loans", loanHandler.GetLoans)
protected.GET("/loans/:id", loanHandler.GetLoanByID)