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