427 lines
13 KiB
Go
427 lines
13 KiB
Go
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())
|
|
}
|
|
}
|