veza/veza-backend-api/internal/api/routes_webhooks.go
senke c10d73da4e
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
feat(subscription): webhook handler closes pending_payment state machine (v1.0.9 item G — Phase 2)
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>
2026-04-27 05:39:59 +02:00

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...)
}