- Add MarketplaceServiceOverride and AuthMiddlewareOverride to config for tests - Wire overrides in routes_webhooks and routes_marketplace (authForMarketplaceInterface) - payment_flow_test: cart -> checkout -> webhook -> order completed, license, transfer - webhook_idempotency_test: 3 identical webhooks -> 1 order, 1 license - webhook_security_test: empty secret 500, invalid sig 401, valid sig 200 - refund_flow_test: completed order -> refund -> order refunded, license revoked - Shared computeWebhookSignature helper in webhook_test_helpers.go - SetMaxOpenConns(1) for sqlite :memory: in idempotency test to avoid flakiness Ref: docs/ROADMAP_V09XX_TO_V1.md v0.912 Cashflow
110 lines
4 KiB
Go
110 lines
4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"veza-backend-api/internal/core/marketplace"
|
|
"veza-backend-api/internal/handlers"
|
|
"veza-backend-api/internal/response"
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/services/hyperswitch"
|
|
"veza-backend-api/internal/workers"
|
|
)
|
|
|
|
// setupWebhookRoutes configure les routes pour les webhooks
|
|
func (r *APIRouter) setupWebhookRoutes(router *gin.RouterGroup) {
|
|
webhookService := services.NewWebhookService(r.db.GormDB, r.logger, r.config.JWTSecret)
|
|
|
|
webhookWorker := workers.NewWebhookWorker(
|
|
r.db.GormDB,
|
|
webhookService,
|
|
r.logger,
|
|
100,
|
|
5,
|
|
3,
|
|
)
|
|
|
|
go webhookWorker.Start(context.Background())
|
|
|
|
webhookHandler := handlers.NewWebhookHandler(webhookService, webhookWorker, r.logger)
|
|
|
|
webhooks := router.Group("/webhooks")
|
|
{
|
|
// Hyperswitch payment webhook - PUBLIC (no auth), called by Hyperswitch
|
|
webhooks.POST("/hyperswitch", r.hyperswitchWebhookHandler())
|
|
|
|
if r.config.AuthMiddleware != nil {
|
|
protected := webhooks.Group("")
|
|
protected.Use(r.config.AuthMiddleware.RequireAuth())
|
|
r.applyCSRFProtection(protected)
|
|
protected.POST("", webhookHandler.RegisterWebhook())
|
|
protected.GET("", webhookHandler.ListWebhooks())
|
|
protected.DELETE("/:id", webhookHandler.DeleteWebhook())
|
|
protected.GET("/stats", webhookHandler.GetWebhookStats())
|
|
protected.POST("/:id/test", webhookHandler.TestWebhook())
|
|
protected.POST("/:id/regenerate-key", webhookHandler.RegenerateAPIKey())
|
|
}
|
|
}
|
|
}
|
|
|
|
// hyperswitchWebhookHandler handles POST /webhooks/hyperswitch from Hyperswitch.
|
|
func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc {
|
|
marketService := r.getMarketplaceService()
|
|
webhookSecret := r.config.HyperswitchWebhookSecret
|
|
return func(c *gin.Context) {
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
r.logger.Error("Hyperswitch webhook: failed to read body", zap.Error(err))
|
|
response.InternalServerError(c, "Failed to read webhook body")
|
|
return
|
|
}
|
|
if webhookSecret == "" {
|
|
r.logger.Error("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not configured, rejecting webhook")
|
|
response.InternalServerError(c, "Webhook secret not configured")
|
|
return
|
|
}
|
|
sig := c.GetHeader("x-webhook-signature-512")
|
|
if err := hyperswitch.VerifyWebhookSignature(body, sig, webhookSecret); err != nil {
|
|
r.logger.Warn("Hyperswitch webhook: signature verification failed", zap.Error(err))
|
|
response.Unauthorized(c, "Invalid webhook signature")
|
|
return
|
|
}
|
|
if err := marketService.ProcessPaymentWebhook(c.Request.Context(), body); err != nil {
|
|
r.logger.Error("Hyperswitch webhook: processing failed", zap.Error(err))
|
|
response.InternalServerError(c, "Webhook processing failed")
|
|
return
|
|
}
|
|
response.Success(c, gin.H{"received": true})
|
|
}
|
|
}
|
|
|
|
// getMarketplaceService returns the marketplace service with Hyperswitch wiring.
|
|
// Used by webhook handler; mirrors setupMarketplaceRoutes service creation.
|
|
func (r *APIRouter) getMarketplaceService() marketplace.MarketplaceService {
|
|
if r.config.MarketplaceServiceOverride != nil {
|
|
return r.config.MarketplaceServiceOverride.(marketplace.MarketplaceService)
|
|
}
|
|
uploadDir := r.config.UploadDir
|
|
if uploadDir == "" {
|
|
uploadDir = "uploads/tracks"
|
|
}
|
|
storageService := services.NewTrackStorageService(uploadDir, false, r.logger)
|
|
opts := []marketplace.ServiceOption{}
|
|
if r.config.HyperswitchEnabled && r.config.HyperswitchAPIKey != "" && r.config.HyperswitchURL != "" {
|
|
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))
|
|
}
|
|
return marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...)
|
|
}
|