veza/veza-backend-api/internal/api/routes_marketplace.go
senke 2281c91e8b feat(v0.13.5): polish marketplace & compliance — KYC, support, payout E2E
- Seller KYC via Stripe Identity (start verification, status check, webhook)
- Support ticket system (backend handler + frontend form page)
- E2E payout flow integration test (sale → payment → balance → payout)
- Migrations: seller_kyc columns, support_tickets table
- Frontend: SupportPage with SUMI design, lazy loading, routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:57:19 +01:00

174 lines
7.6 KiB
Go

package api
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"veza-backend-api/internal/config"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/middleware"
"veza-backend-api/internal/services"
"veza-backend-api/internal/services/hyperswitch"
)
// authForMarketplaceInterface has the auth methods needed by marketplace routes (allows test overrides)
type authForMarketplaceInterface interface {
RequireAuth() gin.HandlerFunc
RequireContentCreatorRole() gin.HandlerFunc
RequireOwnershipOrAdmin(string, middleware.ResourceOwnerResolver) gin.HandlerFunc
}
func (r *APIRouter) authForMarketplace() authForMarketplaceInterface {
if r.config.AuthMiddlewareOverride != nil {
return r.config.AuthMiddlewareOverride.(authForMarketplaceInterface)
}
return r.config.AuthMiddleware
}
// setupMarketplaceRoutes configure les routes de la marketplace
func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
uploadDir := r.config.UploadDir
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
var marketService marketplace.MarketplaceService
var marketServicePtr *marketplace.Service
if r.config.MarketplaceServiceOverride != nil {
marketService = r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService)
marketServicePtr = r.config.MarketplaceServiceOverride.(*marketplace.Service)
} else {
storageService := services.NewTrackStorageService(uploadDir, false, r.logger)
opts := []marketplace.ServiceOption{}
if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" {
if r.config.SentryEnvironment == config.EnvProduction && !r.config.HyperswitchLiveMode {
r.logger.Warn("Hyperswitch is enabled in production but HYPERSWITCH_LIVE_MODE=false; using test keys",
zap.String("hint", "Set HYPERSWITCH_LIVE_MODE=true and use live API keys for production payments"))
}
hsClient := hyperswitch.NewClient(r.config.HyperswitchURL, r.config.HyperswitchAPIKey)
hsProvider := hyperswitch.NewProvider(hsClient)
opts = append(opts,
marketplace.WithPaymentProvider(hsProvider),
marketplace.WithHyperswitchConfig(true, r.config.CheckoutSuccessURL),
)
}
if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
scs := services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
opts = append(opts, marketplace.WithTransferService(scs, r.config.PlatformFeeRate))
}
svc := marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...)
marketService = svc
marketServicePtr = svc
}
var stripeConnectSvc *services.StripeConnectService
if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
stripeConnectSvc = services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
}
productPreviewDir := uploadDir
if productPreviewDir == "" {
productPreviewDir = "uploads"
}
marketHandler := handlers.NewMarketplaceHandler(marketService, r.logger, productPreviewDir)
group := router.Group("/marketplace")
group.GET("/products", marketHandler.ListProducts)
group.GET("/products/:id", marketHandler.GetProduct)
group.GET("/products/:id/preview", marketHandler.StreamProductPreview)
group.GET("/products/:id/reviews", marketHandler.ListReviews)
if auth := r.authForMarketplace(); auth != nil {
protected := group.Group("")
protected.Use(auth.RequireAuth())
r.applyCSRFProtection(protected)
createGroup := protected.Group("")
createGroup.Use(auth.RequireContentCreatorRole())
createGroup.POST("/products", marketHandler.CreateProduct)
createGroup.POST("/products/:id/preview", marketHandler.UploadProductPreview)
productOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
return uuid.Nil, err
}
product, err := marketService.GetProduct(c.Request.Context(), productID)
if err != nil {
return uuid.Nil, err
}
return product.SellerID, nil
}
protected.PUT("/products/:id", auth.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProduct)
protected.PUT("/products/:id/images", auth.RequireOwnershipOrAdmin("product", productOwnerResolver), marketHandler.UpdateProductImages)
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)
protected.POST("/products/:id/reviews", marketHandler.CreateReview)
marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketServicePtr, r.logger)
protected.GET("/wishlist", marketplaceExtHandler.GetWishlist)
protected.POST("/wishlist", marketplaceExtHandler.AddToWishlist)
protected.DELETE("/wishlist/:productId", marketplaceExtHandler.RemoveFromWishlist)
}
sell := router.Group("/sell")
if auth := r.authForMarketplace(); auth != nil {
sellProtected := sell.Group("")
sellProtected.Use(auth.RequireAuth())
sellProtected.Use(auth.RequireContentCreatorRole())
r.applyCSRFProtection(sellProtected)
sellProtected.GET("/stats", marketHandler.GetSellStats)
sellProtected.GET("/stats/evolution", marketHandler.GetSellStatsEvolution)
sellProtected.GET("/stats/top-products", marketHandler.GetSellTopProducts)
sellProtected.GET("/sales", marketHandler.GetSellSales)
sellHandler := handlers.NewSellHandler(r.db.GormDB, stripeConnectSvc, r.logger)
sellProtected.POST("/connect/onboard", sellHandler.ConnectOnboard)
sellProtected.GET("/connect/callback", sellHandler.ConnectCallback)
sellProtected.GET("/balance", sellHandler.GetBalance)
sellProtected.GET("/transfers", sellHandler.GetSellerTransfers)
// v0.12.0 F254: Seller payout management
payoutHandler := handlers.NewPayoutHandler(marketServicePtr, r.logger)
sellProtected.GET("/marketplace-balance", payoutHandler.GetSellerBalance)
sellProtected.GET("/payouts", payoutHandler.GetPayoutHistory)
sellProtected.POST("/payouts/request", payoutHandler.RequestPayout)
// v0.13.5 TASK-MKT-001: KYC seller identity verification
var kycSvc *services.KYCService
if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
kycSvc = services.NewKYCService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
}
kycHandler := handlers.NewKYCHandler(kycSvc, r.logger)
sellProtected.POST("/kyc/start", kycHandler.StartVerification)
sellProtected.GET("/kyc/status", kycHandler.GetVerificationStatus)
}
commerce := router.Group("/commerce")
if auth := r.authForMarketplace(); auth != nil {
cartProtected := commerce.Group("")
cartProtected.Use(auth.RequireAuth())
r.applyCSRFProtection(cartProtected)
marketplaceExtHandler := handlers.NewMarketplaceExtHandler(marketServicePtr, r.logger)
cartProtected.GET("/cart", marketplaceExtHandler.GetCart)
cartProtected.GET("/promo/:code", marketplaceExtHandler.ValidatePromo)
cartProtected.POST("/cart/items", marketplaceExtHandler.AddToCart)
cartProtected.DELETE("/cart/items/:id", marketplaceExtHandler.RemoveFromCart)
cartProtected.POST("/cart/checkout", marketplaceExtHandler.Checkout)
}
// v0.13.5 TASK-MKT-004: Support/Contact form (public endpoint, optional auth)
support := router.Group("/support")
supportHandler := handlers.NewSupportHandler(r.db.GormDB, r.logger)
support.POST("/tickets", supportHandler.SubmitTicket)
}