This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,426 @@
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 = &currency
}
// 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())
}
}