diff options
author | 2025-04-25 02:26:24 +0530 | |
---|---|---|
committer | 2025-04-25 02:26:24 +0530 | |
commit | 5b23b22c60027f18dfb218789eea0e1e6dc38a37 (patch) | |
tree | 8560b4b8ac4d75b154f00f5fad31964b71e4e2ad | |
parent | ff2031f6ff6f4a7e6d441c9ed2372f004ba34499 (diff) | |
download | finance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.tar.gz finance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.tar.bz2 finance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.zip |
finance/backend: feat: added v1/transactions for accounts CRUD
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | backend/cmd/api/main.go | 11 | ||||
-rw-r--r-- | backend/internal/api/v1/transactions/transactions.go | 441 | ||||
-rw-r--r-- | frontend/src/lib/api.ts | 67 |
4 files changed, 520 insertions, 1 deletions
@@ -88,7 +88,7 @@ An application designed to help manage personal finances, including income (like * [x] Add CORS middleware for cross-origin requests * [x] Implement User Authentication (Signup, Login, JWT/Session Management) * [x] Create basic CRUD APIs for Accounts (e.g., Salary Source, Bank Account) -* [ ] Create basic CRUD APIs for Transactions (Income, Expense) +* [x] Create basic CRUD APIs for Transactions (Income, Expense) * [x] Create basic CRUD APIs for Loans * [ ] Create basic CRUD APIs for Goals * [ ] Set up initial logging and error handling diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 9d09012..fd5a0ea 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -8,6 +8,7 @@ import ( "finance/backend/internal/api/auth" "finance/backend/internal/api/v1/accounts" "finance/backend/internal/api/v1/loans" + "finance/backend/internal/api/v1/transactions" "finance/backend/internal/config" "finance/backend/internal/database" @@ -110,6 +111,16 @@ func main() { accountRoutes.DELETE("/:id", accounts.DeleteAccount()) } + // Transaction routes + transactionRoutes := protected.Group("/transactions") + { + transactionRoutes.GET("", transactions.GetTransactions()) + transactionRoutes.GET("/:id", transactions.GetTransactionByID()) + transactionRoutes.POST("", transactions.CreateTransaction()) + transactionRoutes.PUT("/:id", transactions.UpdateTransaction()) + transactionRoutes.DELETE("/:id", transactions.DeleteTransaction()) + } + // Loan routes loanRoutes := protected.Group("/loans") { diff --git a/backend/internal/api/v1/transactions/transactions.go b/backend/internal/api/v1/transactions/transactions.go new file mode 100644 index 0000000..c1ec428 --- /dev/null +++ b/backend/internal/api/v1/transactions/transactions.go @@ -0,0 +1,441 @@ +package transactions + +import ( + "net/http" + "strconv" + "time" + + "finance/backend/internal/database" + "finance/backend/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetTransactions returns all transactions for the authenticated user +// Can be filtered by type (income/expense) and date range +func GetTransactions() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userObj := user.(models.User) + var transactions []models.Transaction + + // Build query + query := database.DB.Where("user_id = ?", userObj.ID) + + // Filter by transaction type if provided + if transactionType := c.Query("type"); transactionType != "" { + if transactionType != "Income" && transactionType != "Expense" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction type. Use 'Income' or 'Expense'"}) + return + } + query = query.Where("type = ?", transactionType) + } + + // Filter by account if provided + if accountID := c.Query("account_id"); accountID != "" { + query = query.Where("account_id = ?", accountID) + } + + // Filter by category if provided + if category := c.Query("category"); category != "" { + query = query.Where("category = ?", category) + } + + // Filter by start date if provided + if startDate := c.Query("start_date"); startDate != "" { + date, err := time.Parse("2006-01-02", startDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid start date format. Use YYYY-MM-DD"}) + return + } + query = query.Where("date >= ?", date) + } + + // Filter by end date if provided + if endDate := c.Query("end_date"); endDate != "" { + date, err := time.Parse("2006-01-02", endDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid end date format. Use YYYY-MM-DD"}) + return + } + // Add a day to include all transactions on the end date + date = date.Add(24 * time.Hour) + query = query.Where("date < ?", date) + } + + // Order by date (newest first) + query = query.Order("date DESC") + + // Execute query + if err := query.Find(&transactions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transactions"}) + return + } + + c.JSON(http.StatusOK, gin.H{"transactions": transactions}) + } +} + +// GetTransactionByID returns a specific transaction by ID +func GetTransactionByID() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get transaction ID from URL parameter + transactionID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID format"}) + return + } + + var transaction models.Transaction + + // Fetch the transaction and ensure it belongs to the authenticated user + if err := database.DB.Where("id = ? AND user_id = ?", transactionID, userObj.ID).First(&transaction).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transaction"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"transaction": transaction}) + } +} + +// CreateTransaction creates a new transaction +func CreateTransaction() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Define a struct to bind the request JSON + var input struct { + 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" binding:"required"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + 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": "Transaction type must be 'Income' or 'Expense'"}) + return + } + + // Parse 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: userObj.ID, + Description: input.Description, + Amount: input.Amount, + Type: input.Type, + Date: date, + Category: input.Category, + AccountID: input.AccountID, + } + + // If an account is specified, verify that it exists and belongs to the user + if input.AccountID != nil { + var account models.Account + if err := database.DB.Where("id = ? AND user_id = ?", *input.AccountID, userObj.ID).First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusBadRequest, gin.H{"error": "Specified account not found or does not belong to you"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify account"}) + } + return + } + + // Update account balance based on transaction type + // For income, add to the balance; for expense, subtract from the balance + tx := database.DB.Begin() + + if input.Type == "Income" { + account.Balance += input.Amount + } else { // Expense + account.Balance -= input.Amount + } + + // Save updated account balance + if err := tx.Save(&account).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update account balance"}) + return + } + + // Save transaction + if err := tx.Create(&transaction).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transaction"}) + return + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"}) + return + } + } else { + // No account specified, just save the transaction + if err := database.DB.Create(&transaction).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transaction"}) + return + } + } + + c.JSON(http.StatusCreated, gin.H{"transaction": transaction}) + } +} + +// UpdateTransaction updates an existing transaction +func UpdateTransaction() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get transaction ID from URL parameter + transactionID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID format"}) + return + } + + // Find existing transaction + var transaction models.Transaction + if err := database.DB.Where("id = ? AND user_id = ?", transactionID, userObj.ID).First(&transaction).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transaction"}) + } + return + } + + // Store original values for comparison + originalAccountID := transaction.AccountID + originalAmount := transaction.Amount + originalType := transaction.Type + + // Define a struct to bind the request JSON + var input struct { + 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"` + AccountID *uint `json:"accountId"` + } + + // Bind JSON to struct + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Begin database transaction for updating both transaction and account balance + tx := database.DB.Begin() + + // Revert original account balance if the transaction affected an account + if originalAccountID != nil { + var originalAccount models.Account + if err := tx.First(&originalAccount, *originalAccountID).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch original account"}) + return + } + + // Reverse the effect of the original transaction + if originalType == "Income" { + originalAccount.Balance -= originalAmount + } else { // Expense + originalAccount.Balance += originalAmount + } + + if err := tx.Save(&originalAccount).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update original account balance"}) + return + } + } + + // Update transaction 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": "Transaction type must be '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 || c.GetHeader("Content-Type") == "application/json" { + transaction.AccountID = input.AccountID + } + + // Update new account balance if the transaction affects an account + if transaction.AccountID != nil { + var newAccount models.Account + if err := tx.First(&newAccount, *transaction.AccountID).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch new account"}) + return + } + + // Apply the effect of the updated transaction + if transaction.Type == "Income" { + newAccount.Balance += transaction.Amount + } else { // Expense + newAccount.Balance -= transaction.Amount + } + + if err := tx.Save(&newAccount).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update new account balance"}) + return + } + } + + // Save transaction + if err := tx.Save(&transaction).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update transaction"}) + return + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"transaction": transaction}) + } +} + +// DeleteTransaction deletes a transaction +func DeleteTransaction() gin.HandlerFunc { + return func(c *gin.Context) { + // Get user from context (set by auth middleware) + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + userObj := user.(models.User) + + // Get transaction ID from URL parameter + transactionID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid transaction ID format"}) + return + } + + // Find the transaction + var transaction models.Transaction + if err := database.DB.Where("id = ? AND user_id = ?", transactionID, userObj.ID).First(&transaction).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaction not found"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transaction"}) + } + return + } + + // Begin database transaction + tx := database.DB.Begin() + + // Update account balance if this transaction was linked to an account + if transaction.AccountID != nil { + var account models.Account + if err := tx.First(&account, *transaction.AccountID).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch account"}) + return + } + + // Reverse the effect of the transaction on the account balance + if transaction.Type == "Income" { + account.Balance -= transaction.Amount + } else { // Expense + account.Balance += transaction.Amount + } + + if err := tx.Save(&account).Error; err != nil { + tx.Rollback() + 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() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete transaction"}) + return + } + + // Commit transaction + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Transaction deleted successfully"}) + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index be77f16..e654007 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -109,6 +109,73 @@ export const accountApi = { }) }; +// Transaction API +export interface Transaction { + ID: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt: string | null; + UserID: number; + AccountID: number | null; + Description: string; + Amount: number; + Type: "Income" | "Expense"; + Date: string; + Category: string; +} + +export interface TransactionInput { + description: string; + amount: number; + type: "Income" | "Expense"; + date: string; // YYYY-MM-DD format + category: string; + accountId?: number; +} + +export interface TransactionFilters { + type?: "Income" | "Expense"; + accountId?: number; + category?: string; + startDate?: string; // YYYY-MM-DD format + endDate?: string; // YYYY-MM-DD format +} + +export const transactionApi = { + getTransactions: (filters?: TransactionFilters) => { + let queryParams = ''; + + if (filters) { + const params = new URLSearchParams(); + if (filters.type) params.append('type', filters.type); + if (filters.accountId) params.append('account_id', filters.accountId.toString()); + if (filters.category) params.append('category', filters.category); + if (filters.startDate) params.append('start_date', filters.startDate); + if (filters.endDate) params.append('end_date', filters.endDate); + + queryParams = `?${params.toString()}`; + } + + return fetchWithAuth(`/transactions${queryParams}`); + }, + + getTransaction: (id: number) => fetchWithAuth(`/transactions/${id}`), + + createTransaction: (transaction: TransactionInput) => fetchWithAuth('/transactions', { + method: 'POST', + body: JSON.stringify(transaction) + }), + + updateTransaction: (id: number, transaction: Partial<TransactionInput>) => fetchWithAuth(`/transactions/${id}`, { + method: 'PUT', + body: JSON.stringify(transaction) + }), + + deleteTransaction: (id: number) => fetchWithAuth(`/transactions/${id}`, { + method: 'DELETE' + }) +}; + // Loan API export interface Loan { ID: number; |