package handler import ( "errors" "strconv" "time" "accounting-app/pkg/api" "accounting-app/internal/models" "accounting-app/internal/service" "github.com/gin-gonic/gin" ) // TransactionHandler handles HTTP requests for transaction operations type TransactionHandler struct { transactionService *service.TransactionService } // NewTransactionHandler creates a new TransactionHandler instance func NewTransactionHandler(transactionService *service.TransactionService) *TransactionHandler { return &TransactionHandler{ transactionService: transactionService, } } // CreateTransactionRequest represents the request body for creating a transaction type CreateTransactionRequest struct { Amount float64 `json:"amount" binding:"required"` Type models.TransactionType `json:"type" binding:"required"` CategoryID uint `json:"category_id" binding:"required"` AccountID uint `json:"account_id" binding:"required"` Currency models.Currency `json:"currency" binding:"required"` TransactionDate string `json:"transaction_date" binding:"required"` Note string `json:"note,omitempty"` ImagePath string `json:"image_path,omitempty"` ToAccountID *uint `json:"to_account_id,omitempty"` TagIDs []uint `json:"tag_ids,omitempty"` } // UpdateTransactionRequest represents the request body for updating a transaction type UpdateTransactionRequest struct { Amount float64 `json:"amount" binding:"required"` Type models.TransactionType `json:"type" binding:"required"` CategoryID uint `json:"category_id" binding:"required"` AccountID uint `json:"account_id" binding:"required"` Currency models.Currency `json:"currency" binding:"required"` TransactionDate string `json:"transaction_date" binding:"required"` Note string `json:"note,omitempty"` ImagePath string `json:"image_path,omitempty"` ToAccountID *uint `json:"to_account_id,omitempty"` TagIDs []uint `json:"tag_ids,omitempty"` } // CreateTransaction handles POST /api/v1/transactions // Creates a new transaction with the provided data // Validates: Requirements 1.1 - 鍒涘缓浜ゆ槗璁板綍 func (h *TransactionHandler) CreateTransaction(c *gin.Context) { var req CreateTransactionRequest if err := c.ShouldBindJSON(&req); err != nil { api.ValidationError(c, "Invalid request body: "+err.Error()) return } // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } // Parse transaction date transactionDate, err := parseTransactionDate(req.TransactionDate) if err != nil { api.BadRequest(c, "Invalid transaction_date format. Use YYYY-MM-DD or RFC3339 format") return } input := service.TransactionInput{ UserID: userID.(uint), Amount: req.Amount, Type: req.Type, CategoryID: req.CategoryID, AccountID: req.AccountID, Currency: req.Currency, TransactionDate: transactionDate, Note: req.Note, ImagePath: req.ImagePath, ToAccountID: req.ToAccountID, TagIDs: req.TagIDs, } transaction, err := h.transactionService.CreateTransaction(userID.(uint), input) if err != nil { handleTransactionError(c, err) return } api.Created(c, transaction) } // GetTransactions handles GET /api/v1/transactions // Returns a list of transactions with pagination and filtering // Validates: Requirements 1.4 - 鏌ョ湅浜ゆ槗鍒楄〃锛堟寜鏃堕棿鍊掑簭锛? func (h *TransactionHandler) GetTransactions(c *gin.Context) { // Parse query parameters input := service.TransactionListInput{} // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } uid := userID.(uint) input.UserID = &uid // Parse date filters if startDateStr := c.Query("start_date"); startDateStr != "" { startDate, err := parseTransactionDate(startDateStr) if err != nil { api.BadRequest(c, "Invalid start_date format. Use YYYY-MM-DD or RFC3339 format") return } input.StartDate = &startDate } if endDateStr := c.Query("end_date"); endDateStr != "" { endDate, err := parseTransactionDate(endDateStr) if err != nil { api.BadRequest(c, "Invalid end_date format. Use YYYY-MM-DD or RFC3339 format") return } input.EndDate = &endDate } // Parse entity filters if categoryIDStr := c.Query("category_id"); categoryIDStr != "" { categoryID, err := strconv.ParseUint(categoryIDStr, 10, 32) if err != nil { api.BadRequest(c, "Invalid category_id") return } catID := uint(categoryID) input.CategoryID = &catID } if accountIDStr := c.Query("account_id"); accountIDStr != "" { accountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { api.BadRequest(c, "Invalid account_id") return } accID := uint(accountID) input.AccountID = &accID } if typeStr := c.Query("type"); typeStr != "" { txnType := models.TransactionType(typeStr) input.Type = &txnType } if currencyStr := c.Query("currency"); currencyStr != "" { currency := models.Currency(currencyStr) input.Currency = ¤cy } // Parse note search input.NoteSearch = c.Query("note_search") // Parse sorting input.SortField = c.Query("sort_field") input.SortAsc = c.Query("sort_asc") == "true" // Parse pagination if offsetStr := c.Query("offset"); offsetStr != "" { offset, err := strconv.Atoi(offsetStr) if err != nil || offset < 0 { api.BadRequest(c, "Invalid offset") return } input.Offset = offset } if limitStr := c.Query("limit"); limitStr != "" { limit, err := strconv.Atoi(limitStr) if err != nil || limit < 0 { api.BadRequest(c, "Invalid limit") return } input.Limit = limit } result, err := h.transactionService.ListTransactions(uid, input) if err != nil { api.InternalError(c, "Failed to get transactions: "+err.Error()) return } // Calculate total pages totalPages := 0 if result.Limit > 0 { totalPages = int((result.Total + int64(result.Limit) - 1) / int64(result.Limit)) } // Calculate current page (1-indexed) currentPage := 1 if result.Limit > 0 { currentPage = (result.Offset / result.Limit) + 1 } api.SuccessWithMeta(c, result.Transactions, &api.Meta{ Page: currentPage, PageSize: result.Limit, TotalCount: result.Total, TotalPages: totalPages, }) } // GetTransaction handles GET /api/v1/transactions/:id // Returns a single transaction by ID func (h *TransactionHandler) GetTransaction(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { api.BadRequest(c, "Invalid transaction ID") return } // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } transaction, err := h.transactionService.GetTransaction(userID.(uint), uint(id)) if err != nil { if errors.Is(err, service.ErrTransactionNotFound) { api.NotFound(c, "Transaction not found") return } api.InternalError(c, "Failed to get transaction: "+err.Error()) return } api.Success(c, transaction) } // UpdateTransaction handles PUT /api/v1/transactions/:id // Updates an existing transaction with the provided data // Validates: Requirements 1.2 - 缂栬緫浜ゆ槗璁板綍 func (h *TransactionHandler) UpdateTransaction(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { api.BadRequest(c, "Invalid transaction ID") return } var req UpdateTransactionRequest if err := c.ShouldBindJSON(&req); err != nil { api.ValidationError(c, "Invalid request body: "+err.Error()) return } // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } // Parse transaction date transactionDate, err := parseTransactionDate(req.TransactionDate) if err != nil { api.BadRequest(c, "Invalid transaction_date format. Use YYYY-MM-DD or RFC3339 format") return } input := service.TransactionInput{ UserID: userID.(uint), Amount: req.Amount, Type: req.Type, CategoryID: req.CategoryID, AccountID: req.AccountID, Currency: req.Currency, TransactionDate: transactionDate, Note: req.Note, ImagePath: req.ImagePath, ToAccountID: req.ToAccountID, TagIDs: req.TagIDs, } transaction, err := h.transactionService.UpdateTransaction(userID.(uint), uint(id), input) if err != nil { handleTransactionError(c, err) return } api.Success(c, transaction) } // DeleteTransaction handles DELETE /api/v1/transactions/:id // Deletes a transaction by ID // Validates: Requirements 1.3 - 鍒犻櫎浜ゆ槗璁板綍 func (h *TransactionHandler) DeleteTransaction(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { api.BadRequest(c, "Invalid transaction ID") return } // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } err = h.transactionService.DeleteTransaction(userID.(uint), uint(id)) if err != nil { if errors.Is(err, service.ErrTransactionNotFound) { api.NotFound(c, "Transaction not found") return } api.InternalError(c, "Failed to delete transaction: "+err.Error()) return } api.NoContent(c) } // GetRelatedTransactions handles GET /api/v1/transactions/:id/related // Returns all related transactions for a given transaction ID // For an expense transaction: returns its refund income and/or reimbursement income if they exist // For a refund/reimbursement income: returns the original expense transaction // Feature: accounting-feature-upgrade // Validates: Requirements 8.21, 8.22 func (h *TransactionHandler) GetRelatedTransactions(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { api.BadRequest(c, "Invalid transaction ID") return } // Get user ID from context userID, exists := c.Get("user_id") if !exists { api.Unauthorized(c, "User not authenticated") return } relatedTransactions, err := h.transactionService.GetRelatedTransactions(userID.(uint), uint(id)) if err != nil { if errors.Is(err, service.ErrTransactionNotFound) { api.NotFound(c, "Transaction not found") return } api.InternalError(c, "Failed to get related transactions: "+err.Error()) return } api.Success(c, relatedTransactions) } // RegisterRoutes registers all transaction routes to the given router group func (h *TransactionHandler) RegisterRoutes(rg *gin.RouterGroup) { transactions := rg.Group("/transactions") { transactions.POST("", h.CreateTransaction) transactions.GET("", h.GetTransactions) transactions.GET("/:id", h.GetTransaction) transactions.PUT("/:id", h.UpdateTransaction) transactions.DELETE("/:id", h.DeleteTransaction) transactions.GET("/:id/related", h.GetRelatedTransactions) } } // parseTransactionDate parses a date string in either YYYY-MM-DD or RFC3339 format func parseTransactionDate(dateStr string) (time.Time, error) { // Try YYYY-MM-DD format first if t, err := time.Parse("2006-01-02", dateStr); err == nil { return t, nil } // Try RFC3339 format if t, err := time.Parse(time.RFC3339, dateStr); err == nil { return t, nil } // Try RFC3339Nano format if t, err := time.Parse(time.RFC3339Nano, dateStr); err == nil { return t, nil } return time.Time{}, errors.New("invalid date format") } // handleTransactionError handles common transaction service errors func handleTransactionError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrTransactionNotFound): api.NotFound(c, "Transaction not found") case errors.Is(err, service.ErrInvalidTransactionType): api.BadRequest(c, "Invalid transaction type. Must be 'income', 'expense', or 'transfer'") case errors.Is(err, service.ErrMissingRequiredField): api.ValidationError(c, err.Error()) case errors.Is(err, service.ErrInvalidAmount): api.BadRequest(c, "Amount must be greater than 0") case errors.Is(err, service.ErrInvalidCurrency): api.BadRequest(c, "Invalid currency. Supported currencies: CNY, USD, EUR, JPY, GBP, HKD") case errors.Is(err, service.ErrCategoryNotFoundForTxn): api.BadRequest(c, "Category not found") case errors.Is(err, service.ErrAccountNotFoundForTxn): api.BadRequest(c, "Account not found") case errors.Is(err, service.ErrToAccountNotFoundForTxn): api.BadRequest(c, "Destination account not found for transfer") case errors.Is(err, service.ErrToAccountRequiredForTxn): api.BadRequest(c, "Destination account is required for transfer transactions") case errors.Is(err, service.ErrSameAccountTransferForTxn): api.BadRequest(c, "Cannot transfer to the same account") case errors.Is(err, service.ErrInsufficientBalance): api.BadRequest(c, "Insufficient balance for this transaction") default: api.InternalError(c, "Failed to process transaction: "+err.Error()) } }