From 9d65a782ca3e2084ef0f560500f6014d7bd09bc0 Mon Sep 17 00:00:00 2001 From: Biswa Kalyan Bhuyan Date: Sat, 26 Apr 2025 01:06:54 +0530 Subject: finance/backend: mvfeat: moved the backend api handlers to api/handlers and added couple of more api request handlers --- backend/cmd/api/main.go | 59 +-- backend/handlers/goal_handler.go | 272 ------------ backend/handlers/goal_handler_test.go | 471 --------------------- backend/internal/api/handlers/account_handler.go | 162 +++++++ backend/internal/api/handlers/auth_handlers.go | 38 ++ backend/internal/api/handlers/goal_handler.go | 272 ++++++++++++ backend/internal/api/handlers/goal_handler_test.go | 471 +++++++++++++++++++++ backend/internal/api/handlers/loan_handler.go | 239 +++++++++++ .../internal/api/handlers/transaction_handler.go | 387 +++++++++++++++++ backend/internal/api/handlers/user_handler.go | 69 +++ backend/internal/router/router.go | 49 ++- 11 files changed, 1683 insertions(+), 806 deletions(-) delete mode 100644 backend/handlers/goal_handler.go delete mode 100644 backend/handlers/goal_handler_test.go create mode 100644 backend/internal/api/handlers/account_handler.go create mode 100644 backend/internal/api/handlers/auth_handlers.go create mode 100644 backend/internal/api/handlers/goal_handler.go create mode 100644 backend/internal/api/handlers/goal_handler_test.go create mode 100644 backend/internal/api/handlers/loan_handler.go create mode 100644 backend/internal/api/handlers/transaction_handler.go create mode 100644 backend/internal/api/handlers/user_handler.go diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index f882175..4884d12 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -6,12 +6,12 @@ import ( "os" "time" - "finance/backend/handlers" "finance/backend/internal/config" "finance/backend/internal/database" "finance/backend/internal/logger" "finance/backend/internal/middleware" "finance/backend/internal/models" + "finance/backend/internal/router" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" @@ -53,17 +53,8 @@ func main() { log.Fatal("Failed to migrate database:", err) } - // Initialize handlers - goalHandler := handlers.NewGoalHandler() - // Initialize other handlers as needed - // userHandler := handlers.NewUserHandler() - // accountHandler := handlers.NewAccountHandler() - // transactionHandler := handlers.NewTransactionHandler() - // loanHandler := handlers.NewLoanHandler() - // authHandler := handlers.NewAuthHandler() - - // Create a new Gin router without default middleware - r := gin.New() + // Initialize the router from the internal/router package + r := router.SetupRouter(cfg) // Add custom middleware r.Use(middleware.RequestLogger(log)) @@ -102,50 +93,6 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Database connection is healthy"}) }) - // API v1 routes - v1 := r.Group("/api/v1") - - // Authentication routes (no JWT required) - // v1.POST("/register", authHandler.Register) - // v1.POST("/login", authHandler.Login) - - // Protected routes (JWT required) - protected := v1.Group("") - // protected.Use(authHandler.JWTAuth) - - // User routes - // protected.GET("/users/me", userHandler.GetCurrentUser) - // protected.PUT("/users/me", userHandler.UpdateCurrentUser) - - // Account routes - // protected.GET("/accounts", accountHandler.GetAccounts) - // protected.GET("/accounts/:id", accountHandler.GetAccountByID) - // protected.POST("/accounts", accountHandler.CreateAccount) - // protected.PUT("/accounts/:id", accountHandler.UpdateAccount) - // protected.DELETE("/accounts/:id", accountHandler.DeleteAccount) - - // Transaction routes - // protected.GET("/transactions", transactionHandler.GetTransactions) - // protected.GET("/transactions/:id", transactionHandler.GetTransactionByID) - // protected.POST("/transactions", transactionHandler.CreateTransaction) - // protected.PUT("/transactions/:id", transactionHandler.UpdateTransaction) - // protected.DELETE("/transactions/:id", transactionHandler.DeleteTransaction) - - // Goal routes - protected.GET("/goals", goalHandler.GetGoals) - protected.GET("/goals/:id", goalHandler.GetGoal) - protected.POST("/goals", goalHandler.CreateGoal) - protected.PUT("/goals/:id", goalHandler.UpdateGoal) - protected.DELETE("/goals/:id", goalHandler.DeleteGoal) - protected.PATCH("/goals/:id/progress", goalHandler.UpdateGoalProgress) - - // 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) - // Start server serverAddr := fmt.Sprintf("%s:%d", cfg.ServerHost, cfg.ServerPort) log.Info("Server starting on", serverAddr) diff --git a/backend/handlers/goal_handler.go b/backend/handlers/goal_handler.go deleted file mode 100644 index 09bea74..0000000 --- a/backend/handlers/goal_handler.go +++ /dev/null @@ -1,272 +0,0 @@ -package handlers - -import ( - "log" - "net/http" - "strconv" - "time" - - "finance/backend/internal/database" - "finance/backend/internal/models" - - "github.com/gin-gonic/gin" -) - -// CreateGoalInput defines the structure for creating a new financial goal -type CreateGoalInput struct { - Name string `json:"name" binding:"required"` - TargetAmount int64 `json:"targetAmount" binding:"required"` - CurrentAmount int64 `json:"currentAmount"` - TargetDate string `json:"targetDate"` // YYYY-MM-DD format - Status string `json:"status"` -} - -// UpdateGoalInput defines the structure for updating an existing financial goal -type UpdateGoalInput struct { - Name string `json:"name"` - TargetAmount int64 `json:"targetAmount"` - CurrentAmount int64 `json:"currentAmount"` - TargetDate string `json:"targetDate"` // YYYY-MM-DD format - Status string `json:"status"` -} - -// UpdateGoalProgressInput defines the structure for updating just the progress of a goal -type UpdateGoalProgressInput struct { - CurrentAmount int64 `json:"currentAmount" binding:"required"` -} - -// GoalHandler handles all goal-related operations in the API -type GoalHandler struct { -} - -// NewGoalHandler creates and returns a new GoalHandler instance -func NewGoalHandler() *GoalHandler { - return &GoalHandler{} -} - -// GetGoals retrieves all goals for the authenticated user -// Optionally filtered by status if provided as a query parameter -func (h *GoalHandler) GetGoals(c *gin.Context) { - userID := c.MustGet("userID").(uint) - var goals []models.Goal - - query := database.DB.Where("user_id = ?", userID) - - // Filter by status if provided - status := c.Query("status") - if status != "" { - query = query.Where("status = ?", status) - } - - if err := query.Find(&goals).Error; err != nil { - log.Printf("Error fetching goals for user %d: %v", userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get goals"}) - return - } - - c.JSON(http.StatusOK, goals) -} - -// GetGoal retrieves a specific goal by ID for the authenticated user -func (h *GoalHandler) GetGoal(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 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 - } - - c.JSON(http.StatusOK, goal) -} - -// CreateGoal creates a new financial goal for the authenticated user -func (h *GoalHandler) CreateGoal(c *gin.Context) { - userID := c.MustGet("userID").(uint) - var input CreateGoalInput - - if err := c.ShouldBindJSON(&input); err != nil { - log.Printf("Error binding JSON for goal creation: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Set default status if not provided - status := "Active" - if input.Status != "" { - status = input.Status - } - - // Parse target date if provided - var targetDate time.Time - if input.TargetDate != "" { - parsedDate, err := time.Parse("2006-01-02", input.TargetDate) - if err != nil { - log.Printf("Error parsing target date for goal creation: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for targetDate. Use YYYY-MM-DD"}) - return - } - targetDate = parsedDate - } - - goal := models.Goal{ - UserID: userID, - Name: input.Name, - TargetAmount: input.TargetAmount, - CurrentAmount: input.CurrentAmount, - Status: status, - } - - // Only set target date if it was provided - if !targetDate.IsZero() { - goal.TargetDate = targetDate - } - - if err := database.DB.Create(&goal).Error; err != nil { - log.Printf("Error creating goal for user %d: %v", userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create goal"}) - return - } - - log.Printf("Goal created successfully for user %d: %s (ID: %d)", userID, goal.Name, goal.ID) - c.JSON(http.StatusCreated, goal) -} - -// UpdateGoal updates an existing goal for the authenticated user -func (h *GoalHandler) UpdateGoal(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 for update: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) - return - } - - 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 in update: %v", goalID, userID, err) - c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) - return - } - - var input UpdateGoalInput - if err := c.ShouldBindJSON(&input); err != nil { - log.Printf("Error binding JSON for goal update: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Update fields that were provided - if input.Name != "" { - goal.Name = input.Name - } - if input.TargetAmount != 0 { - goal.TargetAmount = input.TargetAmount - } - if input.CurrentAmount != 0 { - goal.CurrentAmount = input.CurrentAmount - } - if input.TargetDate != "" { - parsedDate, err := time.Parse("2006-01-02", input.TargetDate) - if err != nil { - log.Printf("Error parsing target date for goal update: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for targetDate. Use YYYY-MM-DD"}) - return - } - goal.TargetDate = parsedDate - } - if input.Status != "" { - goal.Status = input.Status - } - - // Check if goal has been achieved - if goal.CurrentAmount >= goal.TargetAmount { - goal.Status = "Achieved" - log.Printf("Goal ID %d for user %d automatically marked as Achieved", goalID, userID) - } - - if err := database.DB.Save(&goal).Error; err != nil { - log.Printf("Error saving updated goal ID %d for user %d: %v", goalID, userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update goal"}) - return - } - - log.Printf("Goal ID %d updated successfully for user %d", goalID, userID) - c.JSON(http.StatusOK, goal) -} - -// UpdateGoalProgress updates just the progress (current amount) of a goal -func (h *GoalHandler) UpdateGoalProgress(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 for progress update: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) - return - } - - 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 in progress update: %v", goalID, userID, err) - c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) - return - } - - var input UpdateGoalProgressInput - if err := c.ShouldBindJSON(&input); err != nil { - log.Printf("Error binding JSON for goal progress update: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - goal.CurrentAmount = input.CurrentAmount - - // Check if goal has been achieved - if goal.CurrentAmount >= goal.TargetAmount { - goal.Status = "Achieved" - log.Printf("Goal ID %d for user %d automatically marked as Achieved during progress update", goalID, userID) - } - - if err := database.DB.Save(&goal).Error; err != nil { - log.Printf("Error saving progress update for goal ID %d for user %d: %v", goalID, userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update goal progress"}) - return - } - - log.Printf("Goal ID %d progress updated successfully for user %d: %d/%d", goalID, userID, goal.CurrentAmount, goal.TargetAmount) - c.JSON(http.StatusOK, goal) -} - -// DeleteGoal deletes a goal belonging to the authenticated user -func (h *GoalHandler) DeleteGoal(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 for deletion: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) - return - } - - 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 for deletion: %v", goalID, userID, err) - c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) - return - } - - if err := database.DB.Delete(&goal).Error; err != nil { - log.Printf("Error deleting goal ID %d for user %d: %v", goalID, userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete goal"}) - return - } - - log.Printf("Goal ID %d deleted successfully for user %d", goalID, userID) - c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"}) -} diff --git a/backend/handlers/goal_handler_test.go b/backend/handlers/goal_handler_test.go deleted file mode 100644 index d91c000..0000000 --- a/backend/handlers/goal_handler_test.go +++ /dev/null @@ -1,471 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "finance/backend/internal/models" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "gorm.io/gorm" -) - -// Create test goals -func createTestGoals() []models.Goal { - return []models.Goal{ - { - Model: gorm.Model{ID: 1}, - UserID: 1, - Name: "Test Goal 1", - TargetAmount: 1000, - CurrentAmount: 500, - Status: "Active", - }, - { - Model: gorm.Model{ID: 2}, - UserID: 1, - Name: "Test Goal 2", - TargetAmount: 2000, - CurrentAmount: 1000, - Status: "Active", - }, - } -} - -// Test data for a single goal -func createTestGoal() models.Goal { - return models.Goal{ - Model: gorm.Model{ID: 1}, - UserID: 1, - Name: "Test Goal", - TargetAmount: 1000, - CurrentAmount: 500, - Status: "Active", - TargetDate: time.Date(2023, 12, 31, 0, 0, 0, 0, time.UTC), - } -} - -// setupTestRecorder creates a test context with recorder -func setupTestRecorder() (*httptest.ResponseRecorder, *gin.Context) { - gin.SetMode(gin.TestMode) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - return w, c -} - -// TestGetGoals tests the GetGoals handler functionality -func TestGetGoals(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", uint(1)) - req := httptest.NewRequest("GET", "/goals", nil) - c.Request = req - - // Prepare test data - goals := createTestGoals() - - // Directly set response for testing - c.JSON(http.StatusOK, goals) - - // Verify response - assert.Equal(t, http.StatusOK, w.Code) - var responseGoals []models.Goal - err := json.Unmarshal(w.Body.Bytes(), &responseGoals) - assert.NoError(t, err) - assert.Len(t, responseGoals, 2) - assert.Equal(t, "Test Goal 1", responseGoals[0].Name) - assert.Equal(t, "Test Goal 2", responseGoals[1].Name) -} - -// TestGetGoal tests the GetGoal handler -func TestGetGoal(t *testing.T) { - // Test cases - tests := []struct { - name string - userID uint - goalID string - expectedStatus int - expectedError string - }{ - { - name: "Valid Goal ID", - userID: 1, - goalID: "1", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "Invalid Goal ID", - userID: 1, - goalID: "invalid", - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid goal ID", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", tc.userID) - c.AddParam("id", tc.goalID) - req := httptest.NewRequest("GET", "/goals/"+tc.goalID, nil) - c.Request = req - - // Simulate response based on test case - if tc.expectedStatus == http.StatusOK { - goal := createTestGoal() - c.JSON(http.StatusOK, goal) - } else { - c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) - } - - // Verify response - assert.Equal(t, tc.expectedStatus, w.Code) - - if tc.expectedStatus == http.StatusOK { - var goal models.Goal - err := json.Unmarshal(w.Body.Bytes(), &goal) - assert.NoError(t, err) - assert.Equal(t, "Test Goal", goal.Name) - } else { - var errorResponse map[string]string - err := json.Unmarshal(w.Body.Bytes(), &errorResponse) - assert.NoError(t, err) - assert.Equal(t, tc.expectedError, errorResponse["error"]) - } - }) - } -} - -// TestCreateGoal tests the CreateGoal handler -func TestCreateGoal(t *testing.T) { - // Test cases - tests := []struct { - name string - userID uint - input CreateGoalInput - expectedStatus int - expectedError string - }{ - { - name: "Valid Input", - userID: 1, - input: CreateGoalInput{ - Name: "New Goal", - TargetAmount: 5000, - CurrentAmount: 0, - TargetDate: "2024-12-31", - Status: "Active", - }, - expectedStatus: http.StatusCreated, - expectedError: "", - }, - { - name: "Invalid Date Format", - userID: 1, - input: CreateGoalInput{ - Name: "New Goal", - TargetAmount: 5000, - CurrentAmount: 0, - TargetDate: "12/31/2024", // Invalid format - }, - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid date format for targetDate. Use YYYY-MM-DD", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", tc.userID) - - // Create request with input data - jsonData, _ := json.Marshal(tc.input) - req := httptest.NewRequest("POST", "/goals", bytes.NewBuffer(jsonData)) - req.Header.Set("Content-Type", "application/json") - c.Request = req - - // Simulate response based on test case - if tc.expectedStatus == http.StatusCreated { - // Create a response goal from the input - goal := models.Goal{ - Model: gorm.Model{ID: 1}, - UserID: tc.userID, - Name: tc.input.Name, - TargetAmount: tc.input.TargetAmount, - CurrentAmount: tc.input.CurrentAmount, - Status: tc.input.Status, - } - if tc.input.TargetDate != "" { - parsedDate, _ := time.Parse("2006-01-02", tc.input.TargetDate) - goal.TargetDate = parsedDate - } - c.JSON(http.StatusCreated, goal) - } else { - c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) - } - - // Verify response - assert.Equal(t, tc.expectedStatus, w.Code) - - if tc.expectedStatus == http.StatusCreated { - var goal models.Goal - err := json.Unmarshal(w.Body.Bytes(), &goal) - assert.NoError(t, err) - assert.Equal(t, tc.input.Name, goal.Name) - } else { - var errorResponse map[string]string - err := json.Unmarshal(w.Body.Bytes(), &errorResponse) - assert.NoError(t, err) - assert.Equal(t, tc.expectedError, errorResponse["error"]) - } - }) - } -} - -// TestUpdateGoal tests the UpdateGoal handler -func TestUpdateGoal(t *testing.T) { - // Test cases - tests := []struct { - name string - userID uint - goalID string - input UpdateGoalInput - expectedStatus int - expectedError string - }{ - { - name: "Valid Update", - userID: 1, - goalID: "1", - input: UpdateGoalInput{ - Name: "Updated Goal", - TargetAmount: 10000, - CurrentAmount: 1000, - TargetDate: "2025-12-31", - Status: "Active", - }, - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "Invalid Goal ID", - userID: 1, - goalID: "invalid", - input: UpdateGoalInput{ - Name: "Updated Goal", - }, - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid goal ID", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", tc.userID) - c.AddParam("id", tc.goalID) - - // Create request with input data - jsonData, _ := json.Marshal(tc.input) - req := httptest.NewRequest("PUT", "/goals/"+tc.goalID, bytes.NewBuffer(jsonData)) - req.Header.Set("Content-Type", "application/json") - c.Request = req - - // Simulate response based on test case - if tc.expectedStatus == http.StatusOK { - // Create a response goal from the input - goal := models.Goal{ - Model: gorm.Model{ID: 1}, - UserID: tc.userID, - Name: tc.input.Name, - TargetAmount: tc.input.TargetAmount, - CurrentAmount: tc.input.CurrentAmount, - Status: tc.input.Status, - } - if tc.input.TargetDate != "" { - parsedDate, _ := time.Parse("2006-01-02", tc.input.TargetDate) - goal.TargetDate = parsedDate - } - c.JSON(http.StatusOK, goal) - } else { - c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) - } - - // Verify response - assert.Equal(t, tc.expectedStatus, w.Code) - }) - } -} - -// TestDeleteGoal tests the DeleteGoal handler -func TestDeleteGoal(t *testing.T) { - // Test cases - tests := []struct { - name string - userID uint - goalID string - expectedStatus int - expectedError string - }{ - { - name: "Successful Delete", - userID: 1, - goalID: "1", - expectedStatus: http.StatusOK, - expectedError: "", - }, - { - name: "Invalid Goal ID", - userID: 1, - goalID: "invalid", - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid goal ID", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", tc.userID) - c.AddParam("id", tc.goalID) - req := httptest.NewRequest("DELETE", "/goals/"+tc.goalID, nil) - c.Request = req - - // Simulate response based on test case - if tc.expectedStatus == http.StatusOK { - c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"}) - } else { - c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) - } - - // Verify response - assert.Equal(t, tc.expectedStatus, w.Code) - - // Check response body - var response map[string]string - err := json.Unmarshal(w.Body.Bytes(), &response) - assert.NoError(t, err) - - if tc.expectedStatus == http.StatusOK { - assert.Equal(t, "Goal deleted successfully", response["message"]) - } else { - assert.Equal(t, tc.expectedError, response["error"]) - } - }) - } -} - -// TestUpdateGoalProgress tests the UpdateGoalProgress handler -func TestUpdateGoalProgress(t *testing.T) { - // Test cases - tests := []struct { - name string - userID uint - goalID string - input UpdateGoalProgressInput - expectedStatus int - expectedError string - achievesGoal bool - }{ - { - name: "Update Progress", - userID: 1, - goalID: "1", - input: UpdateGoalProgressInput{ - CurrentAmount: 800, - }, - expectedStatus: http.StatusOK, - expectedError: "", - achievesGoal: false, - }, - { - name: "Achieve Goal", - userID: 1, - goalID: "1", - input: UpdateGoalProgressInput{ - CurrentAmount: 1200, // Exceeds target amount of 1000 - }, - expectedStatus: http.StatusOK, - expectedError: "", - achievesGoal: true, - }, - { - name: "Invalid Goal ID", - userID: 1, - goalID: "invalid", - input: UpdateGoalProgressInput{ - CurrentAmount: 800, - }, - expectedStatus: http.StatusBadRequest, - expectedError: "Invalid goal ID", - achievesGoal: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup test environment - w, c := setupTestRecorder() - c.Set("userID", tc.userID) - c.AddParam("id", tc.goalID) - - // Create request with input data - jsonData, _ := json.Marshal(tc.input) - req := httptest.NewRequest("PATCH", "/goals/"+tc.goalID+"/progress", bytes.NewBuffer(jsonData)) - req.Header.Set("Content-Type", "application/json") - c.Request = req - - // Simulate response based on test case - if tc.expectedStatus == http.StatusOK { - // Create a response goal with updated progress - status := "Active" - if tc.achievesGoal { - status = "Achieved" - } - - goal := models.Goal{ - Model: gorm.Model{ID: 1}, - UserID: tc.userID, - Name: "Test Goal", - TargetAmount: 1000, - CurrentAmount: tc.input.CurrentAmount, - Status: status, - TargetDate: time.Date(2023, 12, 31, 0, 0, 0, 0, time.UTC), - } - c.JSON(http.StatusOK, goal) - } else { - c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) - } - - // Verify response - assert.Equal(t, tc.expectedStatus, w.Code) - - if tc.expectedStatus == http.StatusOK { - var goal models.Goal - err := json.Unmarshal(w.Body.Bytes(), &goal) - assert.NoError(t, err) - assert.Equal(t, tc.input.CurrentAmount, goal.CurrentAmount) - - if tc.achievesGoal { - assert.Equal(t, "Achieved", goal.Status) - } else { - assert.Equal(t, "Active", goal.Status) - } - } else { - var errorResponse map[string]string - err := json.Unmarshal(w.Body.Bytes(), &errorResponse) - assert.NoError(t, err) - assert.Equal(t, tc.expectedError, errorResponse["error"]) - } - }) - } -} diff --git a/backend/internal/api/handlers/account_handler.go b/backend/internal/api/handlers/account_handler.go new file mode 100644 index 0000000..5a92ef6 --- /dev/null +++ b/backend/internal/api/handlers/account_handler.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" +) + +// AccountHandler handles account-related operations +type AccountHandler struct { +} + +// NewAccountHandler creates and returns a new AccountHandler instance +func NewAccountHandler() *AccountHandler { + return &AccountHandler{} +} + +// GetAccounts retrieves all accounts for the authenticated user +func (h *AccountHandler) GetAccounts(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var accounts []models.Account + + // Fetch accounts for the current user + if err := database.DB.Where("user_id = ?", userID).Find(&accounts).Error; err != nil { + log.Printf("Error fetching accounts for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get accounts"}) + return + } + + c.JSON(http.StatusOK, accounts) +} + +// GetAccountByID retrieves a specific account by ID for the authenticated user +func (h *AccountHandler) GetAccountByID(c *gin.Context) { + userID := c.MustGet("userID").(uint) + accountID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account ID"}) + return + } + + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", accountID, userID).First(&account).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Account not found"}) + return + } + + c.JSON(http.StatusOK, account) +} + +// CreateAccount creates a new financial account for the authenticated user +func (h *AccountHandler) CreateAccount(c *gin.Context) { + userID := c.MustGet("userID").(uint) + + // Define input structure + type CreateAccountInput struct { + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Balance int64 `json:"balance"` + } + + var input CreateAccountInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Create new account + account := models.Account{ + UserID: userID, + Name: input.Name, + Type: input.Type, + Balance: input.Balance, + } + + if err := database.DB.Create(&account).Error; err != nil { + log.Printf("Error creating account for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"}) + return + } + + c.JSON(http.StatusCreated, account) +} + +// UpdateAccount updates an existing account for the authenticated user +func (h *AccountHandler) UpdateAccount(c *gin.Context) { + userID := c.MustGet("userID").(uint) + accountID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account ID"}) + return + } + + // Find account + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", accountID, userID).First(&account).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Account not found"}) + return + } + + // Define update structure + type UpdateAccountInput struct { + Name string `json:"name"` + Type string `json:"type"` + Balance int64 `json:"balance"` + } + + var input UpdateAccountInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields if provided + if input.Name != "" { + account.Name = input.Name + } + if input.Type != "" { + account.Type = input.Type + } + // For balance updates, we'll accept zero values, so we update unconditionally + account.Balance = input.Balance + + if err := database.DB.Save(&account).Error; err != nil { + log.Printf("Error updating account ID %d for user %d: %v", accountID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account"}) + return + } + + c.JSON(http.StatusOK, account) +} + +// DeleteAccount deletes an account belonging to the authenticated user +func (h *AccountHandler) DeleteAccount(c *gin.Context) { + userID := c.MustGet("userID").(uint) + accountID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account ID"}) + return + } + + // Check if account exists and belongs to user + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", accountID, userID).First(&account).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Account not found"}) + return + } + + // Delete account + if err := database.DB.Delete(&account).Error; err != nil { + log.Printf("Error deleting account ID %d for user %d: %v", accountID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete account"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Account deleted successfully"}) +} diff --git a/backend/internal/api/handlers/auth_handlers.go b/backend/internal/api/handlers/auth_handlers.go new file mode 100644 index 0000000..18d8a1a --- /dev/null +++ b/backend/internal/api/handlers/auth_handlers.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "finance/backend/internal/api/auth" + "finance/backend/internal/config" + + "github.com/gin-gonic/gin" +) + +// AuthHandler handles authentication-related operations +type AuthHandler struct { + config *config.Config +} + +// NewAuthHandler creates and returns a new AuthHandler instance +func NewAuthHandler(cfg *config.Config) *AuthHandler { + return &AuthHandler{ + config: cfg, + } +} + +// Register handles user registration +func (h *AuthHandler) Register(c *gin.Context) { + // Delegate to the existing auth.Signup handler + auth.Signup(h.config)(c) +} + +// Login handles user authentication +func (h *AuthHandler) Login(c *gin.Context) { + // Delegate to the existing auth.Login handler + auth.Login(h.config)(c) +} + +// JWTAuth middleware validates JWT tokens +func (h *AuthHandler) JWTAuth(c *gin.Context) { + // Delegate to the existing auth.AuthMiddleware + auth.AuthMiddleware(h.config)(c) +} diff --git a/backend/internal/api/handlers/goal_handler.go b/backend/internal/api/handlers/goal_handler.go new file mode 100644 index 0000000..09bea74 --- /dev/null +++ b/backend/internal/api/handlers/goal_handler.go @@ -0,0 +1,272 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" +) + +// CreateGoalInput defines the structure for creating a new financial goal +type CreateGoalInput struct { + Name string `json:"name" binding:"required"` + TargetAmount int64 `json:"targetAmount" binding:"required"` + CurrentAmount int64 `json:"currentAmount"` + TargetDate string `json:"targetDate"` // YYYY-MM-DD format + Status string `json:"status"` +} + +// UpdateGoalInput defines the structure for updating an existing financial goal +type UpdateGoalInput struct { + Name string `json:"name"` + TargetAmount int64 `json:"targetAmount"` + CurrentAmount int64 `json:"currentAmount"` + TargetDate string `json:"targetDate"` // YYYY-MM-DD format + Status string `json:"status"` +} + +// UpdateGoalProgressInput defines the structure for updating just the progress of a goal +type UpdateGoalProgressInput struct { + CurrentAmount int64 `json:"currentAmount" binding:"required"` +} + +// GoalHandler handles all goal-related operations in the API +type GoalHandler struct { +} + +// NewGoalHandler creates and returns a new GoalHandler instance +func NewGoalHandler() *GoalHandler { + return &GoalHandler{} +} + +// GetGoals retrieves all goals for the authenticated user +// Optionally filtered by status if provided as a query parameter +func (h *GoalHandler) GetGoals(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var goals []models.Goal + + query := database.DB.Where("user_id = ?", userID) + + // Filter by status if provided + status := c.Query("status") + if status != "" { + query = query.Where("status = ?", status) + } + + if err := query.Find(&goals).Error; err != nil { + log.Printf("Error fetching goals for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get goals"}) + return + } + + c.JSON(http.StatusOK, goals) +} + +// GetGoal retrieves a specific goal by ID for the authenticated user +func (h *GoalHandler) GetGoal(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 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 + } + + c.JSON(http.StatusOK, goal) +} + +// CreateGoal creates a new financial goal for the authenticated user +func (h *GoalHandler) CreateGoal(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var input CreateGoalInput + + if err := c.ShouldBindJSON(&input); err != nil { + log.Printf("Error binding JSON for goal creation: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Set default status if not provided + status := "Active" + if input.Status != "" { + status = input.Status + } + + // Parse target date if provided + var targetDate time.Time + if input.TargetDate != "" { + parsedDate, err := time.Parse("2006-01-02", input.TargetDate) + if err != nil { + log.Printf("Error parsing target date for goal creation: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for targetDate. Use YYYY-MM-DD"}) + return + } + targetDate = parsedDate + } + + goal := models.Goal{ + UserID: userID, + Name: input.Name, + TargetAmount: input.TargetAmount, + CurrentAmount: input.CurrentAmount, + Status: status, + } + + // Only set target date if it was provided + if !targetDate.IsZero() { + goal.TargetDate = targetDate + } + + if err := database.DB.Create(&goal).Error; err != nil { + log.Printf("Error creating goal for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create goal"}) + return + } + + log.Printf("Goal created successfully for user %d: %s (ID: %d)", userID, goal.Name, goal.ID) + c.JSON(http.StatusCreated, goal) +} + +// UpdateGoal updates an existing goal for the authenticated user +func (h *GoalHandler) UpdateGoal(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 for update: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) + return + } + + 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 in update: %v", goalID, userID, err) + c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) + return + } + + var input UpdateGoalInput + if err := c.ShouldBindJSON(&input); err != nil { + log.Printf("Error binding JSON for goal update: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields that were provided + if input.Name != "" { + goal.Name = input.Name + } + if input.TargetAmount != 0 { + goal.TargetAmount = input.TargetAmount + } + if input.CurrentAmount != 0 { + goal.CurrentAmount = input.CurrentAmount + } + if input.TargetDate != "" { + parsedDate, err := time.Parse("2006-01-02", input.TargetDate) + if err != nil { + log.Printf("Error parsing target date for goal update: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format for targetDate. Use YYYY-MM-DD"}) + return + } + goal.TargetDate = parsedDate + } + if input.Status != "" { + goal.Status = input.Status + } + + // Check if goal has been achieved + if goal.CurrentAmount >= goal.TargetAmount { + goal.Status = "Achieved" + log.Printf("Goal ID %d for user %d automatically marked as Achieved", goalID, userID) + } + + if err := database.DB.Save(&goal).Error; err != nil { + log.Printf("Error saving updated goal ID %d for user %d: %v", goalID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update goal"}) + return + } + + log.Printf("Goal ID %d updated successfully for user %d", goalID, userID) + c.JSON(http.StatusOK, goal) +} + +// UpdateGoalProgress updates just the progress (current amount) of a goal +func (h *GoalHandler) UpdateGoalProgress(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 for progress update: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) + return + } + + 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 in progress update: %v", goalID, userID, err) + c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) + return + } + + var input UpdateGoalProgressInput + if err := c.ShouldBindJSON(&input); err != nil { + log.Printf("Error binding JSON for goal progress update: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + goal.CurrentAmount = input.CurrentAmount + + // Check if goal has been achieved + if goal.CurrentAmount >= goal.TargetAmount { + goal.Status = "Achieved" + log.Printf("Goal ID %d for user %d automatically marked as Achieved during progress update", goalID, userID) + } + + if err := database.DB.Save(&goal).Error; err != nil { + log.Printf("Error saving progress update for goal ID %d for user %d: %v", goalID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update goal progress"}) + return + } + + log.Printf("Goal ID %d progress updated successfully for user %d: %d/%d", goalID, userID, goal.CurrentAmount, goal.TargetAmount) + c.JSON(http.StatusOK, goal) +} + +// DeleteGoal deletes a goal belonging to the authenticated user +func (h *GoalHandler) DeleteGoal(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 for deletion: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid goal ID"}) + return + } + + 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 for deletion: %v", goalID, userID, err) + c.JSON(http.StatusNotFound, gin.H{"error": "Goal not found"}) + return + } + + if err := database.DB.Delete(&goal).Error; err != nil { + log.Printf("Error deleting goal ID %d for user %d: %v", goalID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete goal"}) + return + } + + log.Printf("Goal ID %d deleted successfully for user %d", goalID, userID) + c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"}) +} diff --git a/backend/internal/api/handlers/goal_handler_test.go b/backend/internal/api/handlers/goal_handler_test.go new file mode 100644 index 0000000..d91c000 --- /dev/null +++ b/backend/internal/api/handlers/goal_handler_test.go @@ -0,0 +1,471 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "finance/backend/internal/models" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +// Create test goals +func createTestGoals() []models.Goal { + return []models.Goal{ + { + Model: gorm.Model{ID: 1}, + UserID: 1, + Name: "Test Goal 1", + TargetAmount: 1000, + CurrentAmount: 500, + Status: "Active", + }, + { + Model: gorm.Model{ID: 2}, + UserID: 1, + Name: "Test Goal 2", + TargetAmount: 2000, + CurrentAmount: 1000, + Status: "Active", + }, + } +} + +// Test data for a single goal +func createTestGoal() models.Goal { + return models.Goal{ + Model: gorm.Model{ID: 1}, + UserID: 1, + Name: "Test Goal", + TargetAmount: 1000, + CurrentAmount: 500, + Status: "Active", + TargetDate: time.Date(2023, 12, 31, 0, 0, 0, 0, time.UTC), + } +} + +// setupTestRecorder creates a test context with recorder +func setupTestRecorder() (*httptest.ResponseRecorder, *gin.Context) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + return w, c +} + +// TestGetGoals tests the GetGoals handler functionality +func TestGetGoals(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", uint(1)) + req := httptest.NewRequest("GET", "/goals", nil) + c.Request = req + + // Prepare test data + goals := createTestGoals() + + // Directly set response for testing + c.JSON(http.StatusOK, goals) + + // Verify response + assert.Equal(t, http.StatusOK, w.Code) + var responseGoals []models.Goal + err := json.Unmarshal(w.Body.Bytes(), &responseGoals) + assert.NoError(t, err) + assert.Len(t, responseGoals, 2) + assert.Equal(t, "Test Goal 1", responseGoals[0].Name) + assert.Equal(t, "Test Goal 2", responseGoals[1].Name) +} + +// TestGetGoal tests the GetGoal handler +func TestGetGoal(t *testing.T) { + // Test cases + tests := []struct { + name string + userID uint + goalID string + expectedStatus int + expectedError string + }{ + { + name: "Valid Goal ID", + userID: 1, + goalID: "1", + expectedStatus: http.StatusOK, + expectedError: "", + }, + { + name: "Invalid Goal ID", + userID: 1, + goalID: "invalid", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid goal ID", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", tc.userID) + c.AddParam("id", tc.goalID) + req := httptest.NewRequest("GET", "/goals/"+tc.goalID, nil) + c.Request = req + + // Simulate response based on test case + if tc.expectedStatus == http.StatusOK { + goal := createTestGoal() + c.JSON(http.StatusOK, goal) + } else { + c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) + } + + // Verify response + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedStatus == http.StatusOK { + var goal models.Goal + err := json.Unmarshal(w.Body.Bytes(), &goal) + assert.NoError(t, err) + assert.Equal(t, "Test Goal", goal.Name) + } else { + var errorResponse map[string]string + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + assert.NoError(t, err) + assert.Equal(t, tc.expectedError, errorResponse["error"]) + } + }) + } +} + +// TestCreateGoal tests the CreateGoal handler +func TestCreateGoal(t *testing.T) { + // Test cases + tests := []struct { + name string + userID uint + input CreateGoalInput + expectedStatus int + expectedError string + }{ + { + name: "Valid Input", + userID: 1, + input: CreateGoalInput{ + Name: "New Goal", + TargetAmount: 5000, + CurrentAmount: 0, + TargetDate: "2024-12-31", + Status: "Active", + }, + expectedStatus: http.StatusCreated, + expectedError: "", + }, + { + name: "Invalid Date Format", + userID: 1, + input: CreateGoalInput{ + Name: "New Goal", + TargetAmount: 5000, + CurrentAmount: 0, + TargetDate: "12/31/2024", // Invalid format + }, + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid date format for targetDate. Use YYYY-MM-DD", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", tc.userID) + + // Create request with input data + jsonData, _ := json.Marshal(tc.input) + req := httptest.NewRequest("POST", "/goals", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + // Simulate response based on test case + if tc.expectedStatus == http.StatusCreated { + // Create a response goal from the input + goal := models.Goal{ + Model: gorm.Model{ID: 1}, + UserID: tc.userID, + Name: tc.input.Name, + TargetAmount: tc.input.TargetAmount, + CurrentAmount: tc.input.CurrentAmount, + Status: tc.input.Status, + } + if tc.input.TargetDate != "" { + parsedDate, _ := time.Parse("2006-01-02", tc.input.TargetDate) + goal.TargetDate = parsedDate + } + c.JSON(http.StatusCreated, goal) + } else { + c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) + } + + // Verify response + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedStatus == http.StatusCreated { + var goal models.Goal + err := json.Unmarshal(w.Body.Bytes(), &goal) + assert.NoError(t, err) + assert.Equal(t, tc.input.Name, goal.Name) + } else { + var errorResponse map[string]string + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + assert.NoError(t, err) + assert.Equal(t, tc.expectedError, errorResponse["error"]) + } + }) + } +} + +// TestUpdateGoal tests the UpdateGoal handler +func TestUpdateGoal(t *testing.T) { + // Test cases + tests := []struct { + name string + userID uint + goalID string + input UpdateGoalInput + expectedStatus int + expectedError string + }{ + { + name: "Valid Update", + userID: 1, + goalID: "1", + input: UpdateGoalInput{ + Name: "Updated Goal", + TargetAmount: 10000, + CurrentAmount: 1000, + TargetDate: "2025-12-31", + Status: "Active", + }, + expectedStatus: http.StatusOK, + expectedError: "", + }, + { + name: "Invalid Goal ID", + userID: 1, + goalID: "invalid", + input: UpdateGoalInput{ + Name: "Updated Goal", + }, + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid goal ID", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", tc.userID) + c.AddParam("id", tc.goalID) + + // Create request with input data + jsonData, _ := json.Marshal(tc.input) + req := httptest.NewRequest("PUT", "/goals/"+tc.goalID, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + // Simulate response based on test case + if tc.expectedStatus == http.StatusOK { + // Create a response goal from the input + goal := models.Goal{ + Model: gorm.Model{ID: 1}, + UserID: tc.userID, + Name: tc.input.Name, + TargetAmount: tc.input.TargetAmount, + CurrentAmount: tc.input.CurrentAmount, + Status: tc.input.Status, + } + if tc.input.TargetDate != "" { + parsedDate, _ := time.Parse("2006-01-02", tc.input.TargetDate) + goal.TargetDate = parsedDate + } + c.JSON(http.StatusOK, goal) + } else { + c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) + } + + // Verify response + assert.Equal(t, tc.expectedStatus, w.Code) + }) + } +} + +// TestDeleteGoal tests the DeleteGoal handler +func TestDeleteGoal(t *testing.T) { + // Test cases + tests := []struct { + name string + userID uint + goalID string + expectedStatus int + expectedError string + }{ + { + name: "Successful Delete", + userID: 1, + goalID: "1", + expectedStatus: http.StatusOK, + expectedError: "", + }, + { + name: "Invalid Goal ID", + userID: 1, + goalID: "invalid", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid goal ID", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", tc.userID) + c.AddParam("id", tc.goalID) + req := httptest.NewRequest("DELETE", "/goals/"+tc.goalID, nil) + c.Request = req + + // Simulate response based on test case + if tc.expectedStatus == http.StatusOK { + c.JSON(http.StatusOK, gin.H{"message": "Goal deleted successfully"}) + } else { + c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) + } + + // Verify response + assert.Equal(t, tc.expectedStatus, w.Code) + + // Check response body + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + if tc.expectedStatus == http.StatusOK { + assert.Equal(t, "Goal deleted successfully", response["message"]) + } else { + assert.Equal(t, tc.expectedError, response["error"]) + } + }) + } +} + +// TestUpdateGoalProgress tests the UpdateGoalProgress handler +func TestUpdateGoalProgress(t *testing.T) { + // Test cases + tests := []struct { + name string + userID uint + goalID string + input UpdateGoalProgressInput + expectedStatus int + expectedError string + achievesGoal bool + }{ + { + name: "Update Progress", + userID: 1, + goalID: "1", + input: UpdateGoalProgressInput{ + CurrentAmount: 800, + }, + expectedStatus: http.StatusOK, + expectedError: "", + achievesGoal: false, + }, + { + name: "Achieve Goal", + userID: 1, + goalID: "1", + input: UpdateGoalProgressInput{ + CurrentAmount: 1200, // Exceeds target amount of 1000 + }, + expectedStatus: http.StatusOK, + expectedError: "", + achievesGoal: true, + }, + { + name: "Invalid Goal ID", + userID: 1, + goalID: "invalid", + input: UpdateGoalProgressInput{ + CurrentAmount: 800, + }, + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid goal ID", + achievesGoal: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup test environment + w, c := setupTestRecorder() + c.Set("userID", tc.userID) + c.AddParam("id", tc.goalID) + + // Create request with input data + jsonData, _ := json.Marshal(tc.input) + req := httptest.NewRequest("PATCH", "/goals/"+tc.goalID+"/progress", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + // Simulate response based on test case + if tc.expectedStatus == http.StatusOK { + // Create a response goal with updated progress + status := "Active" + if tc.achievesGoal { + status = "Achieved" + } + + goal := models.Goal{ + Model: gorm.Model{ID: 1}, + UserID: tc.userID, + Name: "Test Goal", + TargetAmount: 1000, + CurrentAmount: tc.input.CurrentAmount, + Status: status, + TargetDate: time.Date(2023, 12, 31, 0, 0, 0, 0, time.UTC), + } + c.JSON(http.StatusOK, goal) + } else { + c.JSON(tc.expectedStatus, gin.H{"error": tc.expectedError}) + } + + // Verify response + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedStatus == http.StatusOK { + var goal models.Goal + err := json.Unmarshal(w.Body.Bytes(), &goal) + assert.NoError(t, err) + assert.Equal(t, tc.input.CurrentAmount, goal.CurrentAmount) + + if tc.achievesGoal { + assert.Equal(t, "Achieved", goal.Status) + } else { + assert.Equal(t, "Active", goal.Status) + } + } else { + var errorResponse map[string]string + err := json.Unmarshal(w.Body.Bytes(), &errorResponse) + assert.NoError(t, err) + assert.Equal(t, tc.expectedError, errorResponse["error"]) + } + }) + } +} diff --git a/backend/internal/api/handlers/loan_handler.go b/backend/internal/api/handlers/loan_handler.go new file mode 100644 index 0000000..3edb559 --- /dev/null +++ b/backend/internal/api/handlers/loan_handler.go @@ -0,0 +1,239 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" +) + +// LoanHandler handles loan-related operations +type LoanHandler struct { +} + +// NewLoanHandler creates and returns a new LoanHandler instance +func NewLoanHandler() *LoanHandler { + return &LoanHandler{} +} + +// GetLoans retrieves all loans for the authenticated user +func (h *LoanHandler) GetLoans(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var loans []models.Loan + + if err := database.DB.Where("user_id = ?", userID).Find(&loans).Error; err != nil { + log.Printf("Error fetching loans for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get loans"}) + return + } + + c.JSON(http.StatusOK, loans) +} + +// GetLoanByID retrieves a specific loan by ID for the authenticated user +func (h *LoanHandler) GetLoanByID(c *gin.Context) { + userID := c.MustGet("userID").(uint) + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID"}) + return + } + + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userID).First(&loan).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + return + } + + c.JSON(http.StatusOK, loan) +} + +// CreateLoan creates a new loan for the authenticated user +func (h *LoanHandler) CreateLoan(c *gin.Context) { + userID := c.MustGet("userID").(uint) + + // Define input structure + type CreateLoanInput struct { + Name string `json:"name" binding:"required"` + OriginalAmount int64 `json:"originalAmount" binding:"required"` + CurrentBalance int64 `json:"currentBalance" binding:"required"` + InterestRate float64 `json:"interestRate"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + AccountID *uint `json:"accountId"` + } + + var input CreateLoanInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse dates if provided + var startDate, endDate time.Time + var err error + + if input.StartDate != "" { + startDate, err = time.Parse("2006-01-02", input.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format. Use YYYY-MM-DD"}) + return + } + } + + if input.EndDate != "" { + endDate, err = time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format. Use YYYY-MM-DD"}) + return + } + } + + // Validate account if provided + if input.AccountID != nil { + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", *input.AccountID, userID).First(&account).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Associated account not found or doesn't belong to user"}) + return + } + } + + // Create loan + loan := models.Loan{ + UserID: userID, + AccountID: input.AccountID, + Name: input.Name, + OriginalAmount: input.OriginalAmount, + CurrentBalance: input.CurrentBalance, + InterestRate: input.InterestRate, + } + + // Only set dates if they were provided + if !startDate.IsZero() { + loan.StartDate = startDate + } + if !endDate.IsZero() { + loan.EndDate = endDate + } + + if err := database.DB.Create(&loan).Error; err != nil { + log.Printf("Error creating loan for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create loan"}) + return + } + + c.JSON(http.StatusCreated, loan) +} + +// UpdateLoan updates an existing loan for the authenticated user +func (h *LoanHandler) UpdateLoan(c *gin.Context) { + userID := c.MustGet("userID").(uint) + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID"}) + return + } + + // Find loan + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userID).First(&loan).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + return + } + + // Define update structure + type UpdateLoanInput struct { + Name string `json:"name"` + OriginalAmount int64 `json:"originalAmount"` + CurrentBalance int64 `json:"currentBalance"` + InterestRate float64 `json:"interestRate"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + AccountID *uint `json:"accountId"` + } + + var input UpdateLoanInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields if provided + if input.Name != "" { + loan.Name = input.Name + } + if input.OriginalAmount != 0 { + loan.OriginalAmount = input.OriginalAmount + } + if input.CurrentBalance != 0 { + loan.CurrentBalance = input.CurrentBalance + } + if input.InterestRate != 0 { + loan.InterestRate = input.InterestRate + } + if input.StartDate != "" { + startDate, err := time.Parse("2006-01-02", input.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format. Use YYYY-MM-DD"}) + return + } + loan.StartDate = startDate + } + if input.EndDate != "" { + endDate, err := time.Parse("2006-01-02", input.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format. Use YYYY-MM-DD"}) + return + } + loan.EndDate = endDate + } + if input.AccountID != nil { + // Validate account if provided + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", *input.AccountID, userID).First(&account).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Associated account not found or doesn't belong to user"}) + return + } + loan.AccountID = input.AccountID + } + + // Save changes + if err := database.DB.Save(&loan).Error; err != nil { + log.Printf("Error updating loan ID %d for user %d: %v", loanID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update loan"}) + return + } + + c.JSON(http.StatusOK, loan) +} + +// DeleteLoan deletes a loan belonging to the authenticated user +func (h *LoanHandler) DeleteLoan(c *gin.Context) { + userID := c.MustGet("userID").(uint) + loanID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid loan ID"}) + return + } + + // Check if loan exists and belongs to user + var loan models.Loan + if err := database.DB.Where("id = ? AND user_id = ?", loanID, userID).First(&loan).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Loan not found"}) + return + } + + // Delete loan + if err := database.DB.Delete(&loan).Error; err != nil { + log.Printf("Error deleting loan ID %d for user %d: %v", loanID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete loan"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Loan deleted successfully"}) +} diff --git a/backend/internal/api/handlers/transaction_handler.go b/backend/internal/api/handlers/transaction_handler.go new file mode 100644 index 0000000..542d01a --- /dev/null +++ b/backend/internal/api/handlers/transaction_handler.go @@ -0,0 +1,387 @@ +package handlers + +import ( + "log" + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" +) + +// TransactionHandler handles transaction-related operations +type TransactionHandler struct { +} + +// NewTransactionHandler creates and returns a new TransactionHandler instance +func NewTransactionHandler() *TransactionHandler { + return &TransactionHandler{} +} + +// GetTransactions retrieves all transactions for the authenticated user +// Can be filtered by account, type, and date range +func (h *TransactionHandler) GetTransactions(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var transactions []models.Transaction + + query := database.DB.Where("user_id = ?", userID) + + // Filter by account if provided + if accountIDStr := c.Query("account_id"); accountIDStr != "" { + accountID, err := strconv.ParseUint(accountIDStr, 10, 32) + if err == nil { + query = query.Where("account_id = ?", accountID) + } + } + + // Filter by type if provided (income or expense) + if txType := c.Query("type"); txType != "" { + query = query.Where("type = ?", txType) + } + + // Filter by category if provided + if category := c.Query("category"); category != "" { + query = query.Where("category = ?", category) + } + + // Filter by date range if provided + if fromDate := c.Query("from_date"); fromDate != "" { + date, err := time.Parse("2006-01-02", fromDate) + if err == nil { + query = query.Where("date >= ?", date) + } + } + + if toDate := c.Query("to_date"); toDate != "" { + date, err := time.Parse("2006-01-02", toDate) + if err == nil { + // Add 1 day to include the end date + date = date.Add(24 * time.Hour) + query = query.Where("date < ?", date) + } + } + + // Order by date (most recent first) + query = query.Order("date DESC") + + // Execute the query + if err := query.Find(&transactions).Error; err != nil { + log.Printf("Error fetching transactions for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transactions"}) + return + } + + c.JSON(http.StatusOK, transactions) +} + +// GetTransactionByID retrieves a specific transaction by ID for the authenticated user +func (h *TransactionHandler) GetTransactionByID(c *gin.Context) { + userID := c.MustGet("userID").(uint) + txID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID"}) + return + } + + var transaction models.Transaction + if err := database.DB.Where("id = ? AND user_id = ?", txID, userID).First(&transaction).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + return + } + + c.JSON(http.StatusOK, transaction) +} + +// CreateTransaction creates a new transaction for the authenticated user +func (h *TransactionHandler) CreateTransaction(c *gin.Context) { + userID := c.MustGet("userID").(uint) + + // Define input structure + type CreateTransactionInput struct { + AccountID *uint `json:"accountId"` + Description string `json:"description" binding:"required"` + Amount int64 `json:"amount" binding:"required"` + Type string `json:"type" binding:"required"` // "Income" or "Expense" + Date string `json:"date" binding:"required"` // "YYYY-MM-DD" format + Category string `json:"category"` + } + + var input CreateTransactionInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate transaction type + if input.Type != "Income" && input.Type != "Expense" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Type must be either 'Income' or 'Expense'"}) + return + } + + // Parse the date + date, err := time.Parse("2006-01-02", input.Date) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD"}) + return + } + + // Create transaction object + transaction := models.Transaction{ + UserID: userID, + AccountID: input.AccountID, + Description: input.Description, + Amount: input.Amount, + Type: input.Type, + Date: date, + Category: input.Category, + } + + // Begin a transaction to update both the transaction and account balance + tx := database.DB.Begin() + + // Create the transaction + if err := tx.Create(&transaction).Error; err != nil { + tx.Rollback() + log.Printf("Error creating transaction for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transaction"}) + return + } + + // Update account balance if account ID is provided + if input.AccountID != nil { + var account models.Account + if err := tx.First(&account, *input.AccountID).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "Account not found"}) + return + } + + // Make sure the account belongs to the user + if account.UserID != userID { + tx.Rollback() + c.JSON(http.StatusForbidden, gin.H{"error": "Account does not belong to user"}) + return + } + + // Update the account balance + if input.Type == "Income" { + account.Balance += input.Amount + } else { // Expense + account.Balance -= input.Amount + } + + if err := tx.Save(&account).Error; err != nil { + tx.Rollback() + log.Printf("Error updating account balance for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account balance"}) + return + } + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + log.Printf("Error committing transaction for user %d: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save changes"}) + return + } + + c.JSON(http.StatusCreated, transaction) +} + +// UpdateTransaction updates an existing transaction for the authenticated user +func (h *TransactionHandler) UpdateTransaction(c *gin.Context) { + userID := c.MustGet("userID").(uint) + txID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID"}) + return + } + + // Get the original transaction + var transaction models.Transaction + if err := database.DB.Where("id = ? AND user_id = ?", txID, userID).First(&transaction).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + return + } + + // Define input structure + type UpdateTransactionInput struct { + AccountID *uint `json:"accountId"` + Description string `json:"description"` + Amount int64 `json:"amount"` + Type string `json:"type"` // "Income" or "Expense" + Date string `json:"date"` // "YYYY-MM-DD" format + Category string `json:"category"` + } + + var input UpdateTransactionInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Begin a database transaction + tx := database.DB.Begin() + + // Save original values for account balance calculation + originalAmount := transaction.Amount + originalType := transaction.Type + originalAccountID := transaction.AccountID + + // Update fields if provided + if input.Description != "" { + transaction.Description = input.Description + } + if input.Amount != 0 { + transaction.Amount = input.Amount + } + if input.Type != "" { + if input.Type != "Income" && input.Type != "Expense" { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "Type must be either 'Income' or 'Expense'"}) + return + } + transaction.Type = input.Type + } + if input.Date != "" { + date, err := time.Parse("2006-01-02", input.Date) + if err != nil { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid date format. Use YYYY-MM-DD"}) + return + } + transaction.Date = date + } + if input.Category != "" { + transaction.Category = input.Category + } + if input.AccountID != nil { + transaction.AccountID = input.AccountID + } + + // Update the transaction + if err := tx.Save(&transaction).Error; err != nil { + tx.Rollback() + log.Printf("Error updating transaction ID %d for user %d: %v", txID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update transaction"}) + return + } + + // Handle account balance updates if needed + // 1. Revert the original transaction effect on the original account + if originalAccountID != nil { + var originalAccount models.Account + if err := tx.First(&originalAccount, *originalAccountID).Error; err == nil { + if originalType == "Income" { + originalAccount.Balance -= originalAmount + } else { // Expense + originalAccount.Balance += originalAmount + } + if err := tx.Save(&originalAccount).Error; err != nil { + tx.Rollback() + log.Printf("Error updating original account balance for transaction %d: %v", txID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account balance"}) + return + } + } + } + + // 2. Apply the new transaction effect on the new account + if transaction.AccountID != nil { + var newAccount models.Account + if err := tx.First(&newAccount, *transaction.AccountID).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "Account not found"}) + return + } + + // Make sure the account belongs to the user + if newAccount.UserID != userID { + tx.Rollback() + c.JSON(http.StatusForbidden, gin.H{"error": "Account does not belong to user"}) + return + } + + // Update the account balance + if transaction.Type == "Income" { + newAccount.Balance += transaction.Amount + } else { // Expense + newAccount.Balance -= transaction.Amount + } + + if err := tx.Save(&newAccount).Error; err != nil { + tx.Rollback() + log.Printf("Error updating new account balance for transaction %d: %v", txID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account balance"}) + return + } + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + log.Printf("Error committing updates for transaction %d: %v", txID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save changes"}) + return + } + + c.JSON(http.StatusOK, transaction) +} + +// DeleteTransaction deletes a transaction belonging to the authenticated user +func (h *TransactionHandler) DeleteTransaction(c *gin.Context) { + userID := c.MustGet("userID").(uint) + txID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID"}) + return + } + + // Get the transaction + var transaction models.Transaction + if err := database.DB.Where("id = ? AND user_id = ?", txID, userID).First(&transaction).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + return + } + + // Begin a database transaction + tx := database.DB.Begin() + + // Remove the transaction's effect from the account balance if applicable + if transaction.AccountID != nil { + var account models.Account + if err := tx.First(&account, *transaction.AccountID).Error; err == nil { + if transaction.Type == "Income" { + account.Balance -= transaction.Amount + } else { // Expense + account.Balance += transaction.Amount + } + + if err := tx.Save(&account).Error; err != nil { + tx.Rollback() + log.Printf("Error updating account balance for deleted transaction %d: %v", txID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account balance"}) + return + } + } + } + + // Delete the transaction + if err := tx.Delete(&transaction).Error; err != nil { + tx.Rollback() + log.Printf("Error deleting transaction ID %d for user %d: %v", txID, userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete transaction"}) + return + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + log.Printf("Error committing deletion for transaction %d: %v", txID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save changes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Transaction deleted successfully"}) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go new file mode 100644 index 0000000..aff2d03 --- /dev/null +++ b/backend/internal/api/handlers/user_handler.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "finance/backend/internal/database" + "finance/backend/internal/models" + "net/http" + + "github.com/gin-gonic/gin" +) + +// UserHandler handles user-related operations +type UserHandler struct { +} + +// NewUserHandler creates and returns a new UserHandler instance +func NewUserHandler() *UserHandler { + return &UserHandler{} +} + +// GetCurrentUser returns the authenticated user's information +func (h *UserHandler) GetCurrentUser(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +// UpdateCurrentUser updates the authenticated user's information +func (h *UserHandler) UpdateCurrentUser(c *gin.Context) { + userID := c.MustGet("userID").(uint) + var user models.User + + // Fetch the current user + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Define update structure + type UpdateUserInput struct { + Name string `json:"name"` + } + + var input UpdateUserInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields if provided + if input.Name != "" { + user.Name = input.Name + } + + // Save the changes + if err := database.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"}) + return + } + + // Hide sensitive data + user.PasswordHash = "" + + c.JSON(http.StatusOK, gin.H{"user": user}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b82e1af..3a4d413 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,8 +1,7 @@ package router import ( - "finance/backend/internal/api/auth" - "finance/backend/internal/api/v1/users" + "finance/backend/internal/api/handlers" "finance/backend/internal/config" "finance/backend/internal/database" "net/http" @@ -66,21 +65,57 @@ func SetupRouter(cfg *config.Config) *gin.Engine { }) }) + // Initialize handlers + authHandler := handlers.NewAuthHandler(cfg) + goalHandler := handlers.NewGoalHandler() + userHandler := handlers.NewUserHandler() + accountHandler := handlers.NewAccountHandler() + transactionHandler := handlers.NewTransactionHandler() + loanHandler := handlers.NewLoanHandler() + // API v1 routes v1 := r.Group("/api/v1") { // Auth routes (public) - v1.POST("/auth/signup", auth.Signup(cfg)) - v1.POST("/auth/login", auth.Login(cfg)) + v1.POST("/auth/signup", authHandler.Register) + v1.POST("/auth/login", authHandler.Login) // Protected routes protected := v1.Group("") - protected.Use(auth.AuthMiddleware(cfg)) + protected.Use(authHandler.JWTAuth) { // User routes - protected.GET("/users/me", users.GetCurrentUser) + protected.GET("/users/me", userHandler.GetCurrentUser) + protected.PUT("/users/me", userHandler.UpdateCurrentUser) + + // Account routes + protected.GET("/accounts", accountHandler.GetAccounts) + protected.GET("/accounts/:id", accountHandler.GetAccountByID) + protected.POST("/accounts", accountHandler.CreateAccount) + protected.PUT("/accounts/:id", accountHandler.UpdateAccount) + protected.DELETE("/accounts/:id", accountHandler.DeleteAccount) + + // Transaction routes + protected.GET("/transactions", transactionHandler.GetTransactions) + protected.GET("/transactions/:id", transactionHandler.GetTransactionByID) + protected.POST("/transactions", transactionHandler.CreateTransaction) + protected.PUT("/transactions/:id", transactionHandler.UpdateTransaction) + protected.DELETE("/transactions/:id", transactionHandler.DeleteTransaction) + + // Goal routes + protected.GET("/goals", goalHandler.GetGoals) + protected.GET("/goals/:id", goalHandler.GetGoal) + protected.POST("/goals", goalHandler.CreateGoal) + protected.PUT("/goals/:id", goalHandler.UpdateGoal) + protected.DELETE("/goals/:id", goalHandler.DeleteGoal) + protected.PATCH("/goals/:id/progress", goalHandler.UpdateGoalProgress) - // Add other protected routes here + // 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) } } -- cgit v1.2.3-59-g8ed1b