Files
Novault-backend/internal/handler/recurring_transaction_handler.go
2026-01-25 21:59:00 +08:00

359 lines
11 KiB
Go

package handler
import (
"errors"
"strconv"
"time"
"accounting-app/pkg/api"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"accounting-app/internal/service"
"github.com/gin-gonic/gin"
)
// RecurringTransactionHandler handles HTTP requests for recurring transaction operations
type RecurringTransactionHandler struct {
recurringService *service.RecurringTransactionService
}
// NewRecurringTransactionHandler creates a new RecurringTransactionHandler instance
func NewRecurringTransactionHandler(recurringService *service.RecurringTransactionService) *RecurringTransactionHandler {
return &RecurringTransactionHandler{
recurringService: recurringService,
}
}
// CreateRecurringTransactionRequest represents the request body for creating a recurring transaction
type CreateRecurringTransactionRequest struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
Type models.TransactionType `json:"type" binding:"required,oneof=income expense"`
CategoryID uint `json:"category_id" binding:"required"`
AccountID uint `json:"account_id" binding:"required"`
Currency models.Currency `json:"currency" binding:"required"`
Note string `json:"note"`
Frequency models.FrequencyType `json:"frequency" binding:"required,oneof=daily weekly monthly yearly"`
StartDate string `json:"start_date" binding:"required"`
EndDate *string `json:"end_date"`
}
// UpdateRecurringTransactionRequest represents the request body for updating a recurring transaction
type UpdateRecurringTransactionRequest struct {
Amount *float64 `json:"amount" binding:"omitempty,gt=0"`
Type *models.TransactionType `json:"type" binding:"omitempty,oneof=income expense"`
CategoryID *uint `json:"category_id"`
AccountID *uint `json:"account_id"`
Currency *models.Currency `json:"currency"`
Note *string `json:"note"`
Frequency *models.FrequencyType `json:"frequency" binding:"omitempty,oneof=daily weekly monthly yearly"`
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
ClearEndDate bool `json:"clear_end_date"`
IsActive *bool `json:"is_active"`
}
// CreateRecurringTransaction handles POST /api/v1/recurring-transactions
// Creates a new recurring transaction with the provided data
// Validates: Requirements 1.2.1 - 鍒涘缓鍛ㄦ湡鎬т氦鏄撳苟淇濆瓨鍛ㄦ湡瑙勫垯
func (h *RecurringTransactionHandler) CreateRecurringTransaction(c *gin.Context) {
var req CreateRecurringTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
api.ValidationError(c, "Invalid request body: "+err.Error())
return
}
// Parse start date
startDate, err := parseDate(req.StartDate)
if err != nil {
api.BadRequest(c, "Invalid start_date format. Use YYYY-MM-DD or RFC3339 format")
return
}
// Parse end date if provided
var endDate *time.Time
if req.EndDate != nil {
parsedEndDate, err := parseDate(*req.EndDate)
if err != nil {
api.BadRequest(c, "Invalid end_date format. Use YYYY-MM-DD or RFC3339 format")
return
}
endDate = &parsedEndDate
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
api.Unauthorized(c, "User not authenticated")
return
}
input := service.CreateRecurringTransactionRequest{
UserID: userID.(uint),
Amount: req.Amount,
Type: req.Type,
CategoryID: req.CategoryID,
AccountID: req.AccountID,
Currency: req.Currency,
Note: req.Note,
Frequency: req.Frequency,
StartDate: startDate,
EndDate: endDate,
}
recurringTransaction, err := h.recurringService.Create(input)
if err != nil {
handleRecurringTransactionError(c, err)
return
}
api.Created(c, recurringTransaction)
}
// GetRecurringTransactions handles GET /api/v1/recurring-transactions
// Returns a list of all recurring transactions
func (h *RecurringTransactionHandler) GetRecurringTransactions(c *gin.Context) {
// Check if we should filter by active status
activeOnly := c.Query("active") == "true"
var recurringTransactions []models.RecurringTransaction
var err error
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
api.Unauthorized(c, "User not authenticated")
return
}
if activeOnly {
recurringTransactions, err = h.recurringService.GetActive(userID.(uint))
} else {
recurringTransactions, err = h.recurringService.List(userID.(uint))
}
if err != nil {
api.InternalError(c, "Failed to get recurring transactions: "+err.Error())
return
}
api.Success(c, recurringTransactions)
}
// GetRecurringTransaction handles GET /api/v1/recurring-transactions/:id
// Returns a single recurring transaction by ID
func (h *RecurringTransactionHandler) GetRecurringTransaction(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
api.BadRequest(c, "Invalid recurring transaction ID")
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
api.Unauthorized(c, "User not authenticated")
return
}
recurringTransaction, err := h.recurringService.GetByID(userID.(uint), uint(id))
if err != nil {
if errors.Is(err, repository.ErrRecurringTransactionNotFound) {
api.NotFound(c, "Recurring transaction not found")
return
}
api.InternalError(c, "Failed to get recurring transaction: "+err.Error())
return
}
api.Success(c, recurringTransaction)
}
// UpdateRecurringTransaction handles PUT /api/v1/recurring-transactions/:id
// Updates an existing recurring transaction with the provided data
// Validates: Requirements 1.2.3 - 缂栬緫鍛ㄦ湡鎬т氦鏄撴ā鏉?
func (h *RecurringTransactionHandler) UpdateRecurringTransaction(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
api.BadRequest(c, "Invalid recurring transaction ID")
return
}
var req UpdateRecurringTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
api.ValidationError(c, "Invalid request body: "+err.Error())
return
}
// Build service request
input := service.UpdateRecurringTransactionRequest{
Amount: req.Amount,
Type: req.Type,
CategoryID: req.CategoryID,
AccountID: req.AccountID,
Currency: req.Currency,
Note: req.Note,
Frequency: req.Frequency,
ClearEndDate: req.ClearEndDate,
IsActive: req.IsActive,
}
// Parse start date if provided
if req.StartDate != nil {
startDate, err := parseDate(*req.StartDate)
if err != nil {
api.BadRequest(c, "Invalid start_date format. Use YYYY-MM-DD or RFC3339 format")
return
}
input.StartDate = &startDate
}
// Parse end date if provided
if req.EndDate != nil {
endDate, err := parseDate(*req.EndDate)
if err != nil {
api.BadRequest(c, "Invalid end_date format. Use YYYY-MM-DD or RFC3339 format")
return
}
input.EndDate = &endDate
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
api.Unauthorized(c, "User not authenticated")
return
}
recurringTransaction, err := h.recurringService.Update(userID.(uint), uint(id), input)
if err != nil {
handleRecurringTransactionError(c, err)
return
}
api.Success(c, recurringTransaction)
}
// DeleteRecurringTransaction handles DELETE /api/v1/recurring-transactions/:id
// Deletes a recurring transaction by ID
// Validates: Requirements 1.2.4 - 鍒犻櫎鍛ㄦ湡鎬т氦鏄?
func (h *RecurringTransactionHandler) DeleteRecurringTransaction(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
api.BadRequest(c, "Invalid recurring 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.recurringService.Delete(userID.(uint), uint(id))
if err != nil {
if errors.Is(err, repository.ErrRecurringTransactionNotFound) {
api.NotFound(c, "Recurring transaction not found")
return
}
api.InternalError(c, "Failed to delete recurring transaction: "+err.Error())
return
}
api.NoContent(c)
}
// ProcessDueRecurringTransactions handles POST /api/v1/recurring-transactions/process
// Processes all due recurring transactions and generates actual transactions
// For income transactions, it also triggers matching allocation rules
// Validates: Requirements 1.2.2 - 鍒拌揪鍛ㄦ湡瑙﹀彂鏃堕棿鑷姩鐢熸垚浜ゆ槗璁板綍
func (h *RecurringTransactionHandler) ProcessDueRecurringTransactions(c *gin.Context) {
// Get current time or use provided time for testing
now := time.Now()
if timeStr := c.Query("time"); timeStr != "" {
parsedTime, err := parseDate(timeStr)
if err != nil {
api.BadRequest(c, "Invalid time format. Use YYYY-MM-DD or RFC3339 format")
return
}
now = parsedTime
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
api.Unauthorized(c, "User not authenticated")
return
}
result, err := h.recurringService.ProcessDueTransactions(userID.(uint), now)
if err != nil {
api.InternalError(c, "Failed to process due recurring transactions: "+err.Error())
return
}
api.Success(c, gin.H{
"processed_count": len(result.Transactions),
"transactions": result.Transactions,
"allocations": result.Allocations,
})
}
// RegisterRoutes registers all recurring transaction routes to the given router group
func (h *RecurringTransactionHandler) RegisterRoutes(rg *gin.RouterGroup) {
recurringTransactions := rg.Group("/recurring-transactions")
{
recurringTransactions.POST("", h.CreateRecurringTransaction)
recurringTransactions.GET("", h.GetRecurringTransactions)
recurringTransactions.POST("/process", h.ProcessDueRecurringTransactions)
recurringTransactions.GET("/:id", h.GetRecurringTransaction)
recurringTransactions.PUT("/:id", h.UpdateRecurringTransaction)
recurringTransactions.DELETE("/:id", h.DeleteRecurringTransaction)
}
}
// parseDate parses a date string in either YYYY-MM-DD or RFC3339 format
func parseDate(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")
}
// handleRecurringTransactionError handles common recurring transaction service errors
func handleRecurringTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrRecurringTransactionNotFound):
api.NotFound(c, "Recurring transaction not found")
case errors.Is(err, repository.ErrAccountNotFound):
api.BadRequest(c, "Account not found")
case errors.Is(err, repository.ErrCategoryNotFound):
api.BadRequest(c, "Category not found")
default:
// Check for specific error messages
errMsg := err.Error()
switch {
case errMsg == "currency mismatch: transaction currency CNY does not match account currency USD",
errMsg == "currency mismatch: transaction currency USD does not match account currency CNY":
api.BadRequest(c, errMsg)
case errMsg == "end date must be after start date":
api.BadRequest(c, errMsg)
default:
api.InternalError(c, "Failed to process recurring transaction: "+err.Error())
}
}
}