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()) } } }