aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-25 02:26:24 +0530
committerLibravatarLibravatar Biswa Kalyan Bhuyan <biswa@surgot.in> 2025-04-25 02:26:24 +0530
commit5b23b22c60027f18dfb218789eea0e1e6dc38a37 (patch)
tree8560b4b8ac4d75b154f00f5fad31964b71e4e2ad
parentff2031f6ff6f4a7e6d441c9ed2372f004ba34499 (diff)
downloadfinance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.tar.gz
finance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.tar.bz2
finance-5b23b22c60027f18dfb218789eea0e1e6dc38a37.zip
finance/backend: feat: added v1/transactions for accounts CRUD
-rw-r--r--README.md2
-rw-r--r--backend/cmd/api/main.go11
-rw-r--r--backend/internal/api/v1/transactions/transactions.go441
-rw-r--r--frontend/src/lib/api.ts67
4 files changed, 520 insertions, 1 deletions
diff --git a/README.md b/README.md
index 7b8810a..2f3879b 100644
--- a/README.md
+++ b/README.md
@@ -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;