diff options
Diffstat (limited to 'backend/internal')
-rw-r--r-- | backend/internal/api/handlers/goal_handler.go | 128 | ||||
-rw-r--r-- | backend/internal/api/handlers/loan_handler.go | 312 | ||||
-rw-r--r-- | backend/internal/api/v1/goals/goals.go | 26 | ||||
-rw-r--r-- | backend/internal/api/v1/loans/loans.go | 25 | ||||
-rw-r--r-- | backend/internal/core/goal_service.go | 196 | ||||
-rw-r--r-- | backend/internal/core/goal_service_test.go | 129 | ||||
-rw-r--r-- | backend/internal/database/database.go | 1 | ||||
-rw-r--r-- | backend/internal/models/models.go | 18 | ||||
-rw-r--r-- | backend/internal/router/router.go | 12 |
9 files changed, 844 insertions, 3 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/handlers/loan_handler.go b/backend/internal/api/handlers/loan_handler.go index 3edb559..a80c4b7 100644 --- a/backend/internal/api/handlers/loan_handler.go +++ b/backend/internal/api/handlers/loan_handler.go @@ -10,6 +10,7 @@ import ( "finance/backend/internal/models" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // LoanHandler handles loan-related operations @@ -237,3 +238,314 @@ func (h *LoanHandler) DeleteLoan(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"}) } + +// CreateLoanPayment creates a new payment for a loan +func (h *LoanHandler) CreateLoanPayment(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Define a struct to bind the request JSON + var input struct { + Amount int64 `json:"amount" binding:"required"` + PaymentDate string `json:"paymentDate" binding:"required"` + Principal int64 `json:"principal"` + Interest int64 `json:"interest"` + TransactionID *uint `json:"transactionId"` + Notes string `json:"notes"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse date + paymentDate, err := time.Parse("2006-01-02", input.PaymentDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payment date format"}) + return + } + + // Create payment object + payment := models.LoanPayment{ + UserID: userObj.ID, + LoanID: uint(loanID), + Amount: input.Amount, + PaymentDate: paymentDate, + Principal: input.Principal, + Interest: input.Interest, + TransactionID: input.TransactionID, + Notes: input.Notes, + } + + // Save to database in a transaction + tx := database.DB.Begin() + if err := tx.Create(&payment).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create payment"}) + return + } + + // Update loan balance + loan.CurrentBalance -= input.Principal + if err := tx.Save(&loan).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan balance"}) + return + } + + tx.Commit() + c.JSON(http.StatusCreated, gin.H{"payment": payment, "updatedLoanBalance": loan.CurrentBalance}) +} + +// GetLoanPayments returns all payments for a specific loan +func (h *LoanHandler) GetLoanPayments(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Fetch all payments for the loan + var payments []models.LoanPayment + if err := database.DB.Where("loan_id = ?", loanID).Order("payment_date DESC").Find(&payments).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan payments"}) + return + } + + c.JSON(http.StatusOK, gin.H{"payments": payments}) +} + +// DeleteLoanPayment deletes a payment for a loan +func (h *LoanHandler) DeleteLoanPayment(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get payment ID from URL parameter + paymentID, err := strconv.ParseUint(c.Param("paymentId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payment ID format"}) + return + } + + // Check if the payment exists and belongs to the user + var payment models.LoanPayment + if err := database.DB.Where("id = ? AND user_id = ?", paymentID, userObj.ID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Payment not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch payment"}) + } + return + } + + // Get the loan to update its balance + var loan models.Loan + if err := database.DB.First(&loan, payment.LoanID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch related loan"}) + return + } + + // Update loan and delete payment in a transaction + tx := database.DB.Begin() + + // Reverse the principal payment (add it back to the loan balance) + loan.CurrentBalance += payment.Principal + if err := tx.Save(&loan).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan balance"}) + return + } + + // Delete the payment + if err := tx.Delete(&payment).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete payment"}) + return + } + + tx.Commit() + c.JSON(http.StatusOK, gin.H{"message": "Payment deleted successfully", "updatedLoanBalance": loan.CurrentBalance}) +} + +// GetLoanPaymentSchedule generates an estimated payment schedule for a loan +func (h *LoanHandler) GetLoanPaymentSchedule(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get loan ID from URL parameter + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID format"}) + return + } + + // Check if the loan exists and belongs to the user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userObj.ID).First(&loan).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch loan"}) + } + return + } + + // Parse payment frequency parameter (defaults to monthly) + frequency := c.DefaultQuery("frequency", "monthly") + if frequency != "monthly" && frequency != "biweekly" && frequency != "weekly" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid frequency. Allowed values: monthly, biweekly, weekly"}) + return + } + + // Calculate remaining months (approximately) + now := time.Now() + remainingMonths := int(loan.EndDate.Sub(now).Hours() / 24 / 30) + if remainingMonths <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Loan end date is in the past"}) + return + } + + // Calculate number of payments based on frequency + paymentsCount := remainingMonths + var intervalDays int + if frequency == "weekly" { + paymentsCount = remainingMonths * 4 // ~4 weeks per month + intervalDays = 7 + } else if frequency == "biweekly" { + paymentsCount = remainingMonths * 2 // ~2 bi-weeks per month + intervalDays = 14 + } else { + // Monthly + intervalDays = 30 + } + + // Simple amortization calculation + rate := loan.InterestRate / 12 / 100 // Monthly interest rate + if frequency == "weekly" { + rate = loan.InterestRate / 52 / 100 + } else if frequency == "biweekly" { + rate = loan.InterestRate / 26 / 100 + } + + // For zero interest loans + var paymentAmount int64 + if loan.InterestRate <= 0 { + paymentAmount = loan.CurrentBalance / int64(paymentsCount) + } else { + // Formula: PMT = P * (r * (1+r)^n) / ((1+r)^n - 1) + // Where PMT = payment, P = principal, r = rate per period, n = number of periods + + // Simplified calculation (not exact but good approximation) + totalWithInterest := float64(loan.CurrentBalance) * (1 + float64(paymentsCount)*rate) + paymentAmount = int64(totalWithInterest) / int64(paymentsCount) + } + + // Generate payment schedule + var schedule []map[string]interface{} + balance := loan.CurrentBalance + currentDate := now + + for i := 0; i < paymentsCount && balance > 0; i++ { + // Calculate interest for this period + interestPayment := int64(float64(balance) * rate) + principalPayment := paymentAmount - interestPayment + + // Adjust last payment if needed + if principalPayment > balance { + principalPayment = balance + paymentAmount = principalPayment + interestPayment + } + + // Update remaining balance + balance -= principalPayment + + // Create payment entry + payment := map[string]interface{}{ + "paymentNumber": i + 1, + "date": currentDate.Format("2006-01-02"), + "totalPayment": paymentAmount, + "principalPayment": principalPayment, + "interestPayment": interestPayment, + "remainingBalance": balance, + } + schedule = append(schedule, payment) + + // Increment date based on interval + currentDate = currentDate.AddDate(0, 0, intervalDays) + } + + c.JSON(http.StatusOK, gin.H{ + "loanDetails": loan, + "schedule": schedule, + "summary": map[string]interface{}{ + "totalPayments": len(schedule), + "frequency": frequency, + "estimatedPayoffDate": schedule[len(schedule)-1]["date"], + "totalInterestPaid": sumInterest(schedule), + }, + }) +} + +// Helper function to sum up total interest in a payment schedule +func sumInterest(schedule []map[string]interface{}) int64 { + var total int64 + for _, payment := range schedule { + total += payment["interestPayment"].(int64) + } + return total +} 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/api/v1/loans/loans.go b/backend/internal/api/v1/loans/loans.go index 1366b3b..49ab320 100644 --- a/backend/internal/api/v1/loans/loans.go +++ b/backend/internal/api/v1/loans/loans.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "finance/backend/internal/api/handlers" "finance/backend/internal/database" "finance/backend/internal/models" @@ -246,3 +247,27 @@ func DeleteLoan() gin.HandlerFunc { c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"}) } } + +// GetLoanPayments returns all payments for a specific loan +func GetLoanPayments() gin.HandlerFunc { + handler := handlers.NewLoanHandler() + return handler.GetLoanPayments +} + +// CreateLoanPayment creates a new payment for a loan +func CreateLoanPayment() gin.HandlerFunc { + handler := handlers.NewLoanHandler() + return handler.CreateLoanPayment +} + +// DeleteLoanPayment deletes a loan payment +func DeleteLoanPayment() gin.HandlerFunc { + handler := handlers.NewLoanHandler() + return handler.DeleteLoanPayment +} + +// GetLoanPaymentSchedule generates an estimated payment schedule for a loan +func GetLoanPaymentSchedule() gin.HandlerFunc { + handler := handlers.NewLoanHandler() + return handler.GetLoanPaymentSchedule +} 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/database/database.go b/backend/internal/database/database.go index 15228e2..ca81e01 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -50,6 +50,7 @@ func InitDatabase(cfg *config.Config) error { &models.Transaction{}, &models.Loan{}, &models.Goal{}, + &models.LoanPayment{}, ) if err != nil { log.Printf("Failed to run migrations: %v\n", err) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index c984012..3d409a6 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -94,4 +94,20 @@ type Goal struct { // RelatedLoanID *uint } -// Add other models below (Goal) +// LoanPayment tracks payments made towards a loan +type LoanPayment struct { + gorm.Model + UserID uint `gorm:"not null;index"` // Foreign key to User + User User // Belongs To relationship + LoanID uint `gorm:"not null;index"` // Foreign key to Loan + Loan Loan // Belongs To relationship + Amount int64 `gorm:"not null"` // In smallest currency unit + PaymentDate time.Time `gorm:"not null;index"` // Date payment was made + TransactionID *uint `gorm:"index"` // Optional link to transaction + Transaction *Transaction // Belongs To relationship + Principal int64 // Portion of payment going to principal + Interest int64 // Portion of payment going to interest + Notes string // Any payment notes +} + +// Add other models below diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3a4d413..42753b4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -110,12 +110,24 @@ 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) protected.POST("/loans", loanHandler.CreateLoan) protected.PUT("/loans/:id", loanHandler.UpdateLoan) protected.DELETE("/loans/:id", loanHandler.DeleteLoan) + + // Loan payment routes + protected.GET("/loans/:id/payments", loanHandler.GetLoanPayments) + protected.POST("/loans/:id/payments", loanHandler.CreateLoanPayment) + protected.DELETE("/loans/:id/payments/:paymentId", loanHandler.DeleteLoanPayment) + protected.GET("/loans/:id/payment-schedule", loanHandler.GetLoanPaymentSchedule) } } |