init
This commit is contained in:
358
internal/handler/recurring_transaction_handler.go
Normal file
358
internal/handler/recurring_transaction_handler.go
Normal file
@@ -0,0 +1,358 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user