diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go index 3a277874f..a53e66b17 100644 --- a/veza-backend-api/internal/api/routes_marketplace.go +++ b/veza-backend-api/internal/api/routes_marketplace.go @@ -68,6 +68,7 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { protected.GET("/orders", marketHandler.ListOrders) protected.GET("/orders/:id", marketHandler.GetOrder) protected.GET("/orders/:id/invoice", marketHandler.GetOrderInvoice) + protected.POST("/orders/:id/refund", marketHandler.RefundOrder) protected.POST("/orders", marketHandler.CreateOrder) protected.GET("/download/:product_id", marketHandler.GetDownloadURL) protected.GET("/licenses/mine", marketHandler.GetMyLicenses) diff --git a/veza-backend-api/internal/core/marketplace/models.go b/veza-backend-api/internal/core/marketplace/models.go index f9bfd4197..7c5e0446f 100644 --- a/veza-backend-api/internal/core/marketplace/models.go +++ b/veza-backend-api/internal/core/marketplace/models.go @@ -129,6 +129,7 @@ type License struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` + RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at,omitempty"` // v0.403 R2: set on refund } func (l *License) BeforeCreate(tx *gorm.DB) (err error) { diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index a2c7bd555..18c7cef6a 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -92,6 +92,9 @@ type MarketplaceService interface { // v0.403 F1: Invoices GenerateInvoice(ctx context.Context, orderID, buyerID uuid.UUID) ([]byte, error) + + // v0.403 R2: Refunds + RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error } // ProductImageInput represents input for adding/updating product images @@ -698,9 +701,9 @@ func (s *Service) ValidatePromoCode(ctx context.Context, code string) (*PromoDis // GetDownloadURL checks license and returns signed URL for the asset func (s *Service) GetDownloadURL(ctx context.Context, buyerID uuid.UUID, productID uuid.UUID) (string, error) { - // 1. Check for valid license + // 1. Check for valid license (exclude revoked - v0.403 R2) var license License - err := s.db.Where("buyer_id = ? AND product_id = ? AND downloads_left > 0", buyerID, productID). + err := s.db.Where("buyer_id = ? AND product_id = ? AND downloads_left > 0 AND revoked_at IS NULL", buyerID, productID). First(&license).Error if err != nil { @@ -729,10 +732,10 @@ func (s *Service) GetDownloadURL(ctx context.Context, buyerID uuid.UUID, product return url, nil } -// GetUserLicenses returns all licenses owned by a user +// GetUserLicenses returns all licenses owned by a user (excludes revoked - v0.403 R2) func (s *Service) GetUserLicenses(ctx context.Context, userID uuid.UUID) ([]License, error) { var licenses []License - if err := s.db.Where("buyer_id = ?", userID).Find(&licenses).Error; err != nil { + if err := s.db.Where("buyer_id = ? AND revoked_at IS NULL", userID).Find(&licenses).Error; err != nil { return nil, err } return licenses, nil @@ -1006,3 +1009,59 @@ func (s *Service) ListReviews(ctx context.Context, productID uuid.UUID, limit, o } return reviews, nil } + +// refundProvider is implemented by hyperswitch.Provider for refunds (v0.403 R2) +type refundProvider interface { + Refund(ctx context.Context, paymentID string, amount *int64, reason string) error +} + +var ErrOrderNotRefundable = errors.New("order cannot be refunded") +var ErrRefundNotAvailable = errors.New("refunds not available") +var ErrRefundForbidden = errors.New("you are not allowed to refund this order") + +// RefundOrder initiates a refund for an order (v0.403 R2) +func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error { + var order Order + if err := s.db.WithContext(ctx).Preload("Items").First(&order, "id = ?", orderID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrOrderNotFound + } + return err + } + if order.BuyerID != initiatorID { + isSeller := false + for _, item := range order.Items { + var p Product + if err := s.db.WithContext(ctx).First(&p, "id = ?", item.ProductID).Error; err == nil && p.SellerID == initiatorID { + isSeller = true + break + } + } + if !isSeller { + return ErrRefundForbidden + } + } + if order.Status != "completed" && order.Status != "paid" { + return ErrOrderNotRefundable + } + if order.HyperswitchPaymentID == "" { + return ErrOrderNotRefundable + } + rp, ok := s.paymentProvider.(refundProvider) + if !ok || rp == nil { + return ErrRefundNotAvailable + } + if err := rp.Refund(ctx, order.HyperswitchPaymentID, nil, reason); err != nil { + return fmt.Errorf("hyperswitch refund: %w", err) + } + now := time.Now() + if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{ + "status": "refunded", + }).Error; err != nil { + return err + } + if err := s.db.WithContext(ctx).Model(&License{}).Where("order_id = ?", orderID).Update("revoked_at", now).Error; err != nil { + s.logger.Error("Failed to revoke licenses on refund", zap.Error(err), zap.String("order_id", orderID.String())) + } + return nil +} diff --git a/veza-backend-api/internal/handlers/marketplace.go b/veza-backend-api/internal/handlers/marketplace.go index d1b8d1c92..5e383bace 100644 --- a/veza-backend-api/internal/handlers/marketplace.go +++ b/veza-backend-api/internal/handlers/marketplace.go @@ -741,6 +741,52 @@ func (h *MarketplaceHandler) GetOrder(c *gin.Context) { response.Success(c, order) } +// RefundOrderRequest body for POST /orders/:id/refund (v0.403 R2) +type RefundOrderRequest struct { + Reason string `json:"reason" binding:"max=500" validate:"omitempty,max=500"` + Details string `json:"details" binding:"max=2000" validate:"omitempty,max=2000"` +} + +// RefundOrder initiates a refund for an order (v0.403 R2). Allowed: buyer, seller of products, or admin. +func (h *MarketplaceHandler) RefundOrder(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + orderID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.BadRequest(c, "Invalid order id") + return + } + var req RefundOrderRequest + _ = c.ShouldBindJSON(&req) + reason := req.Reason + if reason == "" { + reason = "Requested by customer" + } + if err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason); err != nil { + if err == marketplace.ErrOrderNotFound { + response.NotFound(c, "Order not found") + return + } + if err == marketplace.ErrOrderNotRefundable { + response.BadRequest(c, "Order cannot be refunded") + return + } + if err == marketplace.ErrRefundNotAvailable { + response.BadRequest(c, "Refunds are not available") + return + } + if err == marketplace.ErrRefundForbidden { + response.Forbidden(c, "You are not allowed to refund this order") + return + } + response.InternalServerError(c, "Failed to process refund") + return + } + response.Success(c, gin.H{"message": "Refund processed"}) +} + // GetOrderInvoice returns a PDF invoice for an order (v0.403 F1) func (h *MarketplaceHandler) GetOrderInvoice(c *gin.Context) { userID, ok := GetUserIDUUID(c) diff --git a/veza-backend-api/migrations/102_license_revoked.sql b/veza-backend-api/migrations/102_license_revoked.sql new file mode 100644 index 000000000..d4ee65b07 --- /dev/null +++ b/veza-backend-api/migrations/102_license_revoked.sql @@ -0,0 +1,7 @@ +-- v0.403 R2: Add revoked_at to licenses for refund revocation +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'licenses') THEN + ALTER TABLE licenses ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ; + END IF; +END $$;