359 lines
11 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|