veza/veza-backend-api/internal/services/hyperswitch/webhook_subscription.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

180 lines
7.2 KiB
Go

package hyperswitch
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/core/subscription"
)
// SubscriptionWebhookProcessor handles Hyperswitch webhooks that target
// subscription invoices (v1.0.9 item G Phase 2 — closes the
// pending_payment state machine opened in Phase 1).
//
// State transitions:
// - pending_payment + status=succeeded → invoice paid, sub active
// - pending_payment + status=failed → invoice failed, sub expired
// - already terminal → idempotent no-op (Hyperswitch
// re-emits webhooks until 200 OK; we must accept replays)
// - payment_id not in subscription_invoices → marketplace.ErrNotASubscription
// (caller falls through to order webhook flow)
//
// The processor only flips a subscription out of pending_payment.
// Subscriptions 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 is consistent.
type SubscriptionWebhookProcessor struct {
db *gorm.DB
logger *zap.Logger
}
// NewSubscriptionWebhookProcessor constructs a processor bound to the
// given DB. The logger is optional; nil is replaced by zap.NewNop so the
// happy path doesn't crash on a forgotten DI wire-up.
func NewSubscriptionWebhookProcessor(db *gorm.DB, logger *zap.Logger) *SubscriptionWebhookProcessor {
if logger == nil {
logger = zap.NewNop()
}
return &SubscriptionWebhookProcessor{db: db, logger: logger}
}
// IsSubscriptionEventType returns true if the event_type matches the
// "subscription.*" prefix used by Item G. The dispatcher in
// marketplace.ProcessPaymentWebhook does NOT rely on this — it always
// tries the invoice lookup first because the PSP can re-emit subscription
// payments with payment_intent.* event types after a recovery flow. The
// helper is exposed for callers that want to short-circuit the DB hit on
// clearly non-subscription events.
func IsSubscriptionEventType(eventType string) bool {
return strings.HasPrefix(strings.ToLower(eventType), "subscription.")
}
// ProcessSubscriptionPayment satisfies marketplace.SubscriptionWebhookHandler.
// Returns marketplace.ErrNotASubscription when the payment_id is not
// associated with any subscription invoice — the caller treats that as
// "not a subscription event" and falls through to the order flow.
func (p *SubscriptionWebhookProcessor) ProcessSubscriptionPayment(ctx context.Context, paymentID, status string) error {
if paymentID == "" {
return errors.New("empty payment_id")
}
var inv subscription.Invoice
if err := p.db.WithContext(ctx).
Where("hyperswitch_payment_id = ?", paymentID).
First(&inv).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return marketplace.ErrNotASubscription
}
return fmt.Errorf("lookup subscription invoice: %w", err)
}
var sub subscription.UserSubscription
if err := p.db.WithContext(ctx).
Where("id = ?", inv.SubscriptionID).
First(&sub).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Invoice exists but subscription is gone — data integrity
// issue worth surfacing rather than swallowing.
return fmt.Errorf("subscription %s not found for invoice %s", inv.SubscriptionID, inv.ID)
}
return fmt.Errorf("lookup subscription: %w", err)
}
switch strings.ToLower(status) {
case "succeeded":
if sub.Status == subscription.StatusActive && inv.Status == subscription.InvoicePaid {
p.logger.Debug("Subscription webhook: already active+paid (idempotent replay)",
zap.String("subscription_id", sub.ID.String()),
zap.String("payment_id", paymentID))
return nil
}
return p.activate(ctx, &inv, &sub, paymentID)
case "failed":
if sub.Status == subscription.StatusExpired && inv.Status == subscription.InvoiceFailed {
p.logger.Debug("Subscription webhook: already expired+failed (idempotent replay)",
zap.String("subscription_id", sub.ID.String()),
zap.String("payment_id", paymentID))
return nil
}
return p.expire(ctx, &inv, &sub, paymentID)
default:
// Intermediate / unknown statuses (processing, requires_*) — log
// and noop. Hyperswitch retries until a terminal status is acked,
// so the transient ones are safe to ignore.
p.logger.Debug("Subscription webhook: ignoring non-terminal status",
zap.String("status", status),
zap.String("payment_id", paymentID),
zap.String("subscription_id", sub.ID.String()))
return nil
}
}
func (p *SubscriptionWebhookProcessor) activate(ctx context.Context, inv *subscription.Invoice, sub *subscription.UserSubscription, paymentID string) error {
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
if err := tx.Model(&subscription.Invoice{}).
Where("id = ?", inv.ID).
Updates(map[string]any{
"status": subscription.InvoicePaid,
"paid_at": now,
}).Error; err != nil {
return fmt.Errorf("update invoice: %w", err)
}
// Only flip the subscription if it's currently pending_payment.
// A row already in active/canceled/upgraded must not be stomped
// by a delayed webhook arrival.
result := tx.Model(&subscription.UserSubscription{}).
Where("id = ? AND status = ?", sub.ID, subscription.StatusPendingPayment).
Updates(map[string]any{"status": subscription.StatusActive})
if result.Error != nil {
return fmt.Errorf("update subscription: %w", result.Error)
}
if result.RowsAffected == 0 {
p.logger.Warn("Subscription webhook: subscription not in pending_payment, invoice still flipped to paid",
zap.String("subscription_id", sub.ID.String()),
zap.String("current_status", string(sub.Status)),
zap.String("payment_id", paymentID))
return nil
}
p.logger.Info("Subscription activated via Hyperswitch webhook",
zap.String("subscription_id", sub.ID.String()),
zap.String("invoice_id", inv.ID.String()),
zap.String("payment_id", paymentID))
return nil
})
}
func (p *SubscriptionWebhookProcessor) expire(ctx context.Context, inv *subscription.Invoice, sub *subscription.UserSubscription, paymentID string) error {
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&subscription.Invoice{}).
Where("id = ?", inv.ID).
Updates(map[string]any{"status": subscription.InvoiceFailed}).Error; err != nil {
return fmt.Errorf("update invoice: %w", err)
}
result := tx.Model(&subscription.UserSubscription{}).
Where("id = ? AND status = ?", sub.ID, subscription.StatusPendingPayment).
Updates(map[string]any{"status": subscription.StatusExpired})
if result.Error != nil {
return fmt.Errorf("update subscription: %w", result.Error)
}
if result.RowsAffected == 0 {
p.logger.Warn("Subscription webhook: subscription not in pending_payment, invoice still flipped to failed",
zap.String("subscription_id", sub.ID.String()),
zap.String("current_status", string(sub.Status)),
zap.String("payment_id", paymentID))
return nil
}
p.logger.Info("Subscription expired via Hyperswitch webhook",
zap.String("subscription_id", sub.ID.String()),
zap.String("invoice_id", inv.ID.String()),
zap.String("payment_id", paymentID))
return nil
})
}