package subscription import ( "context" "errors" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/zap" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // TestGetUserSubscription_PaymentGate exercises the v1.0.6.2 hotfix: a row in // active/trialing state that lacks an effective payment linkage must not be // returned as a valid subscription. A single test covers the full branch // matrix of hasEffectivePayment so a regression in any clause is caught. func TestGetUserSubscription_PaymentGate(t *testing.T) { ctx := context.Background() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&Plan{}, &UserSubscription{}, &Invoice{})) svc := NewService(db, zap.NewNop()) freePlan := Plan{ID: uuid.New(), Name: PlanFree, DisplayName: "Free", PriceMonthly: 0, IsActive: true} paidPlan := Plan{ID: uuid.New(), Name: PlanCreator, DisplayName: "Creator", PriceMonthly: 999, IsActive: true} require.NoError(t, db.Create(&freePlan).Error) require.NoError(t, db.Create(&paidPlan).Error) now := time.Now() future := now.Add(24 * time.Hour) past := now.Add(-24 * time.Hour) tests := []struct { name string prepare func(t *testing.T, userID uuid.UUID) // sets up the row(s) for this user expectErr error }{ { name: "no subscription row returns ErrNoActiveSubscription", prepare: func(t *testing.T, userID uuid.UUID) { // no-op }, expectErr: ErrNoActiveSubscription, }, { name: "free plan active always passes", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: freePlan.ID, Status: StatusActive, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, } require.NoError(t, db.Create(&sub).Error) }, expectErr: nil, }, { name: "paid plan active with PSP payment intent on invoice passes", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusActive, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, } require.NoError(t, db.Create(&sub).Error) inv := Invoice{ SubscriptionID: sub.ID, UserID: userID, AmountCents: 999, Currency: "USD", Status: InvoicePending, BillingPeriodStart: now, BillingPeriodEnd: future, HyperswitchPaymentID: "pay_12345", } require.NoError(t, db.Create(&inv).Error) }, expectErr: nil, }, { name: "paid plan active with invoice but empty hs_payment_id returns ErrSubscriptionNoPayment", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusActive, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, } require.NoError(t, db.Create(&sub).Error) inv := Invoice{ SubscriptionID: sub.ID, UserID: userID, AmountCents: 999, Currency: "USD", Status: InvoicePending, BillingPeriodStart: now, BillingPeriodEnd: future, HyperswitchPaymentID: "", } require.NoError(t, db.Create(&inv).Error) }, expectErr: ErrSubscriptionNoPayment, }, { name: "paid plan active with no invoice at all returns ErrSubscriptionNoPayment", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusActive, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, } require.NoError(t, db.Create(&sub).Error) }, expectErr: ErrSubscriptionNoPayment, }, { name: "paid plan trialing with future trial_end passes", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusTrialing, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, TrialStart: &now, TrialEnd: &future, } require.NoError(t, db.Create(&sub).Error) }, expectErr: nil, }, { name: "paid plan trialing with past trial_end and no payment returns ErrSubscriptionNoPayment", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusTrialing, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, TrialStart: &past, TrialEnd: &past, } require.NoError(t, db.Create(&sub).Error) }, expectErr: ErrSubscriptionNoPayment, }, { name: "paid plan trialing with nil trial_end and no payment returns ErrSubscriptionNoPayment", prepare: func(t *testing.T, userID uuid.UUID) { sub := UserSubscription{ UserID: userID, PlanID: paidPlan.ID, Status: StatusTrialing, BillingCycle: BillingMonthly, CurrentPeriodStart: now, CurrentPeriodEnd: future, } require.NoError(t, db.Create(&sub).Error) }, expectErr: ErrSubscriptionNoPayment, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { userID := uuid.New() tc.prepare(t, userID) sub, err := svc.GetUserSubscription(ctx, userID) if tc.expectErr == nil { require.NoError(t, err) require.NotNil(t, sub) require.Equal(t, userID, sub.UserID) return } require.Error(t, err) require.True(t, errors.Is(err, tc.expectErr), "expected error %v, got %v", tc.expectErr, err) }) } } // fakePaymentProvider records the calls it received so the tests can // assert on idempotency-key threading + arg shapes. Returns // deterministic payment_id / client_secret pairs. type fakePaymentProvider struct { calls []fakePaymentCall createErr error getStatusValue string } type fakePaymentCall struct { idempotencyKey string amountCents int currency string subscriptionID string metadata map[string]string } func (f *fakePaymentProvider) CreateSubscriptionPayment(_ context.Context, idempotencyKey string, amountCents int, currency, subscriptionID, _ string, metadata map[string]string) (string, string, error) { f.calls = append(f.calls, fakePaymentCall{ idempotencyKey: idempotencyKey, amountCents: amountCents, currency: currency, subscriptionID: subscriptionID, metadata: metadata, }) if f.createErr != nil { return "", "", f.createErr } return "pay_" + idempotencyKey[:8], "sec_" + idempotencyKey[:8], nil } func (f *fakePaymentProvider) GetPayment(_ context.Context, _ string) (string, error) { return f.getStatusValue, nil } // TestSubscribe_PendingPaymentStateMachine exercises v1.0.9 item G's // state machine on the new-subscription path. Covers the visible // outcomes : free plan stays active, paid plan with provider goes to // pending_payment + idempotency-key threaded, paid plan without // provider returns ErrPaymentProviderRequired (mapped to 503 in the // handler), trial-eligible first-time user gets trialing without a // PSP call, and a repeat user (already used trial) falls through to // pending_payment. func TestSubscribe_PendingPaymentStateMachine(t *testing.T) { ctx := context.Background() freePlan := Plan{ ID: uuid.New(), Name: PlanFree, DisplayName: "Free", PriceMonthly: 0, IsActive: true, } paidPlan := Plan{ ID: uuid.New(), Name: PlanCreator, DisplayName: "Creator", PriceMonthly: 999, PriceYearly: 9990, Currency: "USD", IsActive: true, } paidPlanWithTrial := Plan{ ID: uuid.New(), Name: PlanPremium, DisplayName: "Premium", PriceMonthly: 1999, Currency: "USD", TrialDays: 14, IsActive: true, } type setup struct { plan Plan provider *fakePaymentProvider seedTrial bool // pre-seed a trial row so the trial-eligibility branch is exercised } cases := []struct { name string setup setup expectStatus SubscriptionStatus expectPSP bool expectErr error }{ { name: "free plan stays active and skips provider", setup: setup{ plan: freePlan, provider: &fakePaymentProvider{}, }, expectStatus: StatusActive, expectPSP: false, }, { name: "paid plan + provider configured -> pending_payment + PSP call", setup: setup{ plan: paidPlan, provider: &fakePaymentProvider{}, }, expectStatus: StatusPendingPayment, expectPSP: true, }, { name: "paid plan + no provider -> ErrPaymentProviderRequired", setup: setup{ plan: paidPlan, provider: nil, }, expectErr: ErrPaymentProviderRequired, }, { name: "trial-eligible first-time user -> trialing, no PSP call", setup: setup{ plan: paidPlanWithTrial, provider: &fakePaymentProvider{}, }, expectStatus: StatusTrialing, expectPSP: false, }, { name: "trial repeat user -> falls through to pending_payment", setup: setup{ plan: paidPlanWithTrial, provider: &fakePaymentProvider{}, seedTrial: true, }, expectStatus: StatusPendingPayment, expectPSP: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&Plan{}, &UserSubscription{}, &Invoice{})) require.NoError(t, db.Create(&tc.setup.plan).Error) opts := []ServiceOption{} if tc.setup.provider != nil { opts = append(opts, WithPaymentProvider(tc.setup.provider)) } svc := NewService(db, zap.NewNop(), opts...) userID := uuid.New() if tc.setup.seedTrial { past := time.Now().Add(-30 * 24 * time.Hour) old := UserSubscription{ UserID: userID, PlanID: tc.setup.plan.ID, Status: StatusExpired, BillingCycle: BillingMonthly, CurrentPeriodStart: past, CurrentPeriodEnd: past.Add(time.Hour), TrialStart: &past, TrialEnd: &past, } require.NoError(t, db.Create(&old).Error) } resp, err := svc.Subscribe(ctx, userID, SubscribeRequest{ PlanID: tc.setup.plan.ID, BillingCycle: BillingMonthly, }) if tc.expectErr != nil { require.Error(t, err) require.True(t, errors.Is(err, tc.expectErr), "expected %v, got %v", tc.expectErr, err) return } require.NoError(t, err) require.NotNil(t, resp.Subscription) require.Equal(t, tc.expectStatus, resp.Subscription.Status) if tc.expectPSP { require.Len(t, tc.setup.provider.calls, 1, "expected exactly one PSP call") call := tc.setup.provider.calls[0] // Idempotency key must be the new subscription row's // UUID — protects against retried HTTP requests // collapsing into one PSP charge. require.Equal(t, resp.Subscription.ID.String(), call.idempotencyKey) require.NotEmpty(t, call.idempotencyKey) require.Equal(t, resp.Subscription.ID.String(), call.subscriptionID) require.Equal(t, "USD", call.currency) require.Equal(t, tc.setup.plan.PriceMonthly, call.amountCents) require.Equal(t, userID.String(), call.metadata["user_id"]) require.NotEmpty(t, resp.ClientSecret, "client_secret returned to caller") require.NotEmpty(t, resp.PaymentID, "payment_id returned to caller") } else if tc.setup.provider != nil { require.Len(t, tc.setup.provider.calls, 0, "expected zero PSP calls when expectPSP=false") } }) } }