Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m18s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m22s
Veza CI / Frontend (Web) (push) Failing after 19m45s
E2E Playwright / e2e (full) (push) Failing after 20m45s
Veza CI / Backend (Go) (push) Failing after 22m38s
Veza CI / Notify on failure (push) Successful in 7s
Phase 1 (commit 2a96766a) opened the pending_payment status: a paid-plan
subscribe path creates a UserSubscription row in pending_payment +
subscription_invoices row carrying the Hyperswitch payment_id, then hands
the client_secret back to the SPA. Phase 2 lands the webhook side: the
PSP-driven state transition that closes the loop.
State machine:
- pending_payment + status=succeeded → invoice paid (paid_at=now), sub active
- pending_payment + status=failed → invoice failed, sub expired
- already terminal → idempotent no-op (paid_at NOT bumped)
- payment_id not in subscription_invoices → marketplace.ErrNotASubscription
(caller falls through to the order webhook flow)
The processor only flips a subscription out of pending_payment. Rows that
have already transitioned (concurrent flow, manual admin action, plan
upgrade) are left alone — the invoice still gets the terminal status
update so the audit trail stays consistent.
New surface:
- hyperswitch.SubscriptionWebhookProcessor — the actual handler. Reads
subscription_invoices by hyperswitch_payment_id, looks up the parent
user_subscriptions row, applies the transition in a single tx.
- hyperswitch.IsSubscriptionEventType — exported helper for callers
that want to skip the DB hit on clearly non-subscription events.
- marketplace.SubscriptionWebhookHandler (interface) +
marketplace.ErrNotASubscription (sentinel) — keeps marketplace from
importing the hyperswitch package while still allowing
ProcessPaymentWebhook to dispatch typed.
- marketplace.WithSubscriptionWebhookHandler (option) — wired by
routes_webhooks.getMarketplaceService so the prod webhook handler
routes subscription events instead of swallowing them as "order not
found".
Dispatcher in ProcessPaymentWebhook: try subscription first, fall through
to the order flow on ErrNotASubscription. Order events are unchanged.
Tests (4, sqlite in-memory, all green):
- Succeeded: pending_payment → active+paid, paid_at set
- Failed: pending_payment → expired+failed
- Idempotent replay: second succeeded webhook is a no-op, paid_at NOT
re-stamped (locks down Hyperswitch's at-least-once delivery contract)
- Unknown payment_id: returns marketplace.ErrNotASubscription so the
dispatcher falls through to ProcessPaymentWebhook's order flow
Removes the v1.0.6.2 "active row without PSP linkage" fantôme pattern
that hasEffectivePayment had to filter retroactively — the Phase 1 +
Phase 2 pair is now the canonical paid-plan creation path.
E2E + recovery endpoint (POST /api/v1/subscriptions/complete/:id) +
distribution gate land in Phase 3 (Day 3 of ROADMAP_V1.0_LAUNCH.md).
SKIP_TESTS=1 rationale: this commit is backend-only (Go); the husky
pre-commit hook only runs frontend typecheck/lint/vitest. Backend tests
verified manually:
$ go test -short -count=1 ./internal/services/hyperswitch/... ./internal/core/marketplace/... ./internal/core/subscription/...
ok veza-backend-api/internal/services/hyperswitch
ok veza-backend-api/internal/core/marketplace
ok veza-backend-api/internal/core/subscription
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.2 KiB
Go
212 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"unicode/utf8"
|
|
|
|
"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.
|
|
//
|
|
// v1.0.7 item E: every delivery is persisted to hyperswitch_webhook_log,
|
|
// including deliveries that fail signature verification or processing
|
|
// — the log is the forensic audit trail for both legitimate activity
|
|
// and attack probes. A 64KB body cap protects the table from log-spam
|
|
// DoS (rejected with 413 before any INSERT).
|
|
func (r *APIRouter) hyperswitchWebhookHandler() gin.HandlerFunc {
|
|
marketService := r.getMarketplaceService()
|
|
webhookSecret := r.config.HyperswitchWebhookSecret
|
|
return func(c *gin.Context) {
|
|
// v1.0.7 item E: cap the body read at MaxWebhookPayloadBytes+1
|
|
// so we can detect oversize without loading arbitrary megabytes
|
|
// into memory. An attacker posting 10MB gets 413 back; the log
|
|
// table never sees the row.
|
|
limited := io.LimitReader(c.Request.Body, hyperswitch.MaxWebhookPayloadBytes+1)
|
|
body, err := io.ReadAll(limited)
|
|
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 len(body) > hyperswitch.MaxWebhookPayloadBytes {
|
|
r.logger.Warn("Hyperswitch webhook: payload exceeds size cap, rejecting",
|
|
zap.Int("size_bytes", len(body)),
|
|
zap.Int("max_bytes", hyperswitch.MaxWebhookPayloadBytes))
|
|
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{
|
|
"error": "payload too large",
|
|
"limit": hyperswitch.MaxWebhookPayloadBytes,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Payload as TEXT: Hyperswitch sends JSON, but a probe/attack
|
|
// might send anything. Reject invalid UTF-8 outright — the
|
|
// attack-forensics value is in headers + ip + timing, not the
|
|
// binary body.
|
|
payload := string(body)
|
|
if !utf8.ValidString(payload) {
|
|
r.logger.Warn("Hyperswitch webhook: payload contains invalid UTF-8, replacing with empty for log")
|
|
payload = ""
|
|
}
|
|
|
|
// Collect forensics context regardless of outcome.
|
|
requestID := c.GetHeader("X-Request-Id")
|
|
if requestID == "" {
|
|
requestID = c.GetHeader("X-Request-ID")
|
|
}
|
|
sigHeader := c.GetHeader("x-webhook-signature-512")
|
|
logRow := &hyperswitch.WebhookLog{
|
|
Payload: payload,
|
|
SignatureHeader: sigHeader,
|
|
SourceIP: c.ClientIP(),
|
|
UserAgent: c.GetHeader("User-Agent"),
|
|
RequestID: requestID, // filled with UUID by LogWebhook if empty
|
|
}
|
|
|
|
// Signature verification. Regardless of outcome, the row lands
|
|
// with signature_valid set accordingly.
|
|
if webhookSecret == "" {
|
|
r.logger.Error("Hyperswitch webhook: HYPERSWITCH_WEBHOOK_SECRET not configured, rejecting webhook")
|
|
logRow.SignatureValid = false
|
|
logRow.ProcessingResult = "error: webhook secret not configured"
|
|
r.persistWebhookLog(c.Request.Context(), logRow)
|
|
response.InternalServerError(c, "Webhook secret not configured")
|
|
return
|
|
}
|
|
if err := hyperswitch.VerifyWebhookSignature(body, sigHeader, webhookSecret); err != nil {
|
|
r.logger.Warn("Hyperswitch webhook: signature verification failed",
|
|
zap.String("source_ip", logRow.SourceIP),
|
|
zap.Error(err))
|
|
logRow.SignatureValid = false
|
|
logRow.ProcessingResult = "signature_invalid"
|
|
r.persistWebhookLog(c.Request.Context(), logRow)
|
|
response.Unauthorized(c, "Invalid webhook signature")
|
|
return
|
|
}
|
|
logRow.SignatureValid = true
|
|
|
|
// v1.0.6: dispatch refund events to ProcessRefundWebhook. Payment
|
|
// events keep flowing through ProcessPaymentWebhook unchanged.
|
|
var peek marketplace.HyperswitchWebhookPayload
|
|
if err := json.Unmarshal(body, &peek); err != nil {
|
|
r.logger.Warn("Hyperswitch webhook: payload not JSON — dispatching as payment",
|
|
zap.Error(err))
|
|
}
|
|
var procErr error
|
|
if peek.IsRefundEvent() {
|
|
procErr = marketService.ProcessRefundWebhook(c.Request.Context(), body)
|
|
} else {
|
|
procErr = marketService.ProcessPaymentWebhook(c.Request.Context(), body)
|
|
}
|
|
if procErr != nil {
|
|
r.logger.Error("Hyperswitch webhook: processing failed",
|
|
zap.Bool("is_refund_event", peek.IsRefundEvent()),
|
|
zap.Error(procErr))
|
|
logRow.ProcessingResult = "error: " + procErr.Error()
|
|
r.persistWebhookLog(c.Request.Context(), logRow)
|
|
response.InternalServerError(c, "Webhook processing failed")
|
|
return
|
|
}
|
|
logRow.ProcessingResult = "ok"
|
|
r.persistWebhookLog(c.Request.Context(), logRow)
|
|
response.Success(c, gin.H{"received": true})
|
|
}
|
|
}
|
|
|
|
// persistWebhookLog writes the audit row. Any DB failure is logged and
|
|
// swallowed — the endpoint's primary contract is to ack Hyperswitch,
|
|
// not to persist audit perfectly. A persistent storage failure is
|
|
// operationally visible via this log line.
|
|
func (r *APIRouter) persistWebhookLog(ctx context.Context, row *hyperswitch.WebhookLog) {
|
|
if r.db == nil || r.db.GormDB == nil {
|
|
return
|
|
}
|
|
if err := hyperswitch.LogWebhook(ctx, r.db.GormDB, row); err != nil {
|
|
r.logger.Error("Hyperswitch webhook: failed to persist audit log row",
|
|
zap.String("request_id", row.RequestID),
|
|
zap.Bool("signature_valid", row.SignatureValid),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
// v1.0.9 item G Phase 2: subscription webhook dispatcher. The
|
|
// processor reads/writes subscription_invoices + user_subscriptions
|
|
// directly via GORM; injecting it here makes ProcessPaymentWebhook
|
|
// route subscription events instead of swallowing them as "order not
|
|
// found".
|
|
subscriptionProcessor := hyperswitch.NewSubscriptionWebhookProcessor(r.db.GormDB, r.logger)
|
|
opts = append(opts, marketplace.WithSubscriptionWebhookHandler(subscriptionProcessor))
|
|
return marketplace.NewService(r.db.GormDB, r.logger, storageService, opts...)
|
|
}
|