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

162 lines
6 KiB
Go

package hyperswitch
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/core/marketplace"
"veza-backend-api/internal/core/subscription"
)
func setupSubscriptionWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&subscription.Plan{},
&subscription.UserSubscription{},
&subscription.Invoice{},
))
return db
}
// seedPendingPayment creates a paid-plan subscription frozen in
// pending_payment, with an invoice carrying the supplied payment_id —
// the canonical post-Phase-1 row shape that the webhook handler is
// supposed to flip.
func seedPendingPayment(t *testing.T, db *gorm.DB, paymentID string) (subscription.UserSubscription, subscription.Invoice) {
t.Helper()
plan := subscription.Plan{
ID: uuid.New(),
Name: subscription.PlanCreator,
DisplayName: "Creator",
PriceMonthly: 999,
Currency: "USD",
IsActive: true,
}
require.NoError(t, db.Create(&plan).Error)
now := time.Now()
sub := subscription.UserSubscription{
UserID: uuid.New(),
PlanID: plan.ID,
Status: subscription.StatusPendingPayment,
BillingCycle: subscription.BillingMonthly,
CurrentPeriodStart: now,
CurrentPeriodEnd: now.AddDate(0, 1, 0),
}
require.NoError(t, db.Create(&sub).Error)
inv := subscription.Invoice{
SubscriptionID: sub.ID,
UserID: sub.UserID,
AmountCents: 999,
Currency: "USD",
Status: subscription.InvoicePending,
BillingPeriodStart: now,
BillingPeriodEnd: now.AddDate(0, 1, 0),
HyperswitchPaymentID: paymentID,
}
require.NoError(t, db.Create(&inv).Error)
return sub, inv
}
// TestSubscriptionWebhookProcessor_Succeeded covers the canonical happy
// path: pending_payment + status=succeeded flips both rows to terminal
// state (sub=active, invoice=paid+paid_at).
func TestSubscriptionWebhookProcessor_Succeeded(t *testing.T) {
db := setupSubscriptionWebhookDB(t)
sub, inv := seedPendingPayment(t, db, "pay_succ_1")
p := NewSubscriptionWebhookProcessor(db, zap.NewNop())
require.NoError(t, p.ProcessSubscriptionPayment(context.Background(), "pay_succ_1", "succeeded"))
var refreshedSub subscription.UserSubscription
require.NoError(t, db.First(&refreshedSub, "id = ?", sub.ID).Error)
assert.Equal(t, subscription.StatusActive, refreshedSub.Status)
var refreshedInv subscription.Invoice
require.NoError(t, db.First(&refreshedInv, "id = ?", inv.ID).Error)
assert.Equal(t, subscription.InvoicePaid, refreshedInv.Status)
require.NotNil(t, refreshedInv.PaidAt, "paid_at must be set after activation")
}
// TestSubscriptionWebhookProcessor_Failed covers the dual: pending_payment
// + status=failed flips both rows to the rejection-terminal state
// (sub=expired, invoice=failed). Phase 1 created the row optimistically,
// the failed webhook is what shuts it down without granting access.
func TestSubscriptionWebhookProcessor_Failed(t *testing.T) {
db := setupSubscriptionWebhookDB(t)
sub, inv := seedPendingPayment(t, db, "pay_fail_1")
p := NewSubscriptionWebhookProcessor(db, zap.NewNop())
require.NoError(t, p.ProcessSubscriptionPayment(context.Background(), "pay_fail_1", "failed"))
var refreshedSub subscription.UserSubscription
require.NoError(t, db.First(&refreshedSub, "id = ?", sub.ID).Error)
assert.Equal(t, subscription.StatusExpired, refreshedSub.Status)
var refreshedInv subscription.Invoice
require.NoError(t, db.First(&refreshedInv, "id = ?", inv.ID).Error)
assert.Equal(t, subscription.InvoiceFailed, refreshedInv.Status)
}
// TestSubscriptionWebhookProcessor_IdempotentReplay locks down the
// at-least-once delivery contract: Hyperswitch retries until 200 OK,
// so a second succeeded webhook must be a no-op (state unchanged,
// no error, paid_at NOT bumped to "now").
func TestSubscriptionWebhookProcessor_IdempotentReplay(t *testing.T) {
db := setupSubscriptionWebhookDB(t)
sub, inv := seedPendingPayment(t, db, "pay_replay_1")
p := NewSubscriptionWebhookProcessor(db, zap.NewNop())
// First delivery: pending_payment → active.
require.NoError(t, p.ProcessSubscriptionPayment(context.Background(), "pay_replay_1", "succeeded"))
var inv1 subscription.Invoice
require.NoError(t, db.First(&inv1, "id = ?", inv.ID).Error)
require.NotNil(t, inv1.PaidAt)
paidAt1 := *inv1.PaidAt
// Sleep to make sure any spurious paid_at re-write would have a
// detectable later timestamp (millisecond resolution suffices).
time.Sleep(10 * time.Millisecond)
// Replay: same payment_id, same status, no error, no state change.
require.NoError(t, p.ProcessSubscriptionPayment(context.Background(), "pay_replay_1", "succeeded"))
var sub2 subscription.UserSubscription
require.NoError(t, db.First(&sub2, "id = ?", sub.ID).Error)
assert.Equal(t, subscription.StatusActive, sub2.Status)
var inv2 subscription.Invoice
require.NoError(t, db.First(&inv2, "id = ?", inv.ID).Error)
assert.Equal(t, subscription.InvoicePaid, inv2.Status)
require.NotNil(t, inv2.PaidAt)
assert.True(t, paidAt1.Equal(*inv2.PaidAt),
"paid_at must NOT be re-stamped on idempotent replay (got %v after %v)", *inv2.PaidAt, paidAt1)
}
// TestSubscriptionWebhookProcessor_UnknownPaymentID locks down the
// fall-through contract: a payment_id that has no subscription invoice
// returns marketplace.ErrNotASubscription so the dispatcher in
// ProcessPaymentWebhook can route the event to the order flow.
func TestSubscriptionWebhookProcessor_UnknownPaymentID(t *testing.T) {
db := setupSubscriptionWebhookDB(t)
p := NewSubscriptionWebhookProcessor(db, zap.NewNop())
err := p.ProcessSubscriptionPayment(context.Background(), "pay_does_not_exist", "succeeded")
require.Error(t, err)
assert.True(t, errors.Is(err, marketplace.ErrNotASubscription),
"expected marketplace.ErrNotASubscription, got %v", err)
}