aboutsummaryrefslogtreecommitdiffstats
path: root/backend/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'backend/internal/api')
-rw-r--r--backend/internal/api/handlers/account_handler.go162
-rw-r--r--backend/internal/api/handlers/auth_handlers.go38
-rw-r--r--backend/internal/api/handlers/goal_handler.go272
-rw-r--r--backend/internal/api/handlers/goal_handler_test.go471
-rw-r--r--backend/internal/api/handlers/loan_handler.go239
-rw-r--r--backend/internal/api/handlers/transaction_handler.go387
-rw-r--r--backend/internal/api/handlers/user_handler.go69
7 files changed, 1638 insertions, 0 deletions
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})
+}