veza/veza-backend-api/internal/core/subscription/gate_test.go
senke 2a96766ae3 feat(subscription): pending_payment state machine + mandatory provider (v1.0.9 item G — Phase 1)
First instalment of Item G from docs/audit-2026-04/v107-plan.md §G.
This commit lands the state machine + create-flow change. Phase 2
(webhook handler + recovery endpoint + reconciler sweep) follows.

What changes :
  - **`models.go`** — adds `StatusPendingPayment` to the
    SubscriptionStatus enum. Free-text VARCHAR(30) so no DDL needed
    for the value itself; Phase 2's reconciler index lives in
    migration 986 (additive, partial index on `created_at` WHERE
    status='pending_payment').
  - **`service.go`** — `PaymentProvider.CreateSubscriptionPayment`
    interface gains an `idempotencyKey string` parameter, mirroring
    the marketplace.refundProvider contract added in v1.0.7 item D.
    Callers pass the new subscription row's UUID so a retried HTTP
    request collapses to one PSP charge instead of duplicating it.
  - **`createNewSubscription`** — refactored state machine :
      * Free plan → StatusActive (unchanged, in subscribeToFreePlan).
      * Paid plan, trial available, first-time user → StatusTrialing,
        no PSP call (no invoice either — Phase 2 will create the
        first paid invoice on trial expiry).
      * Paid plan, no trial / repeat user → **StatusPendingPayment**
        + invoice + PSP CreateSubscriptionPayment with idempotency
        key = subscription.ID.String(). Webhook
        subscription.payment_succeeded (Phase 2) flips to active;
        subscription.payment_failed flips to expired.
  - **`if s.paymentProvider != nil` short-circuit removed**. Paid
    plans now require a configured PaymentProvider — without one,
    `createNewSubscription` returns ErrPaymentProviderRequired. The
    handler maps this to HTTP 503 "Payment provider not configured —
    paid plans temporarily unavailable", surfacing env misconfig to
    ops instead of silently giving away paid plans (the v1.0.6.2
    fantôme bug class).
  - **`GetUserSubscription` query unchanged** — already filters on
    `status IN ('active','trialing')`, so pending_payment rows
    correctly read as "no active subscription" for feature-gate
    purposes. The v1.0.6.2 hasEffectivePayment filter is kept as
    defence-in-depth for legacy rows.
  - **`hyperswitch.Provider`** — implements
    `subscription.PaymentProvider` by delegating to the existing
    `CreatePaymentSimple`. Compile-time interface assertion added
    (`var _ subscription.PaymentProvider = (*Provider)(nil)`).
  - **`routes_subscription.go`** — wires the Hyperswitch provider
    into `subscription.NewService` when HyperswitchEnabled +
    HyperswitchAPIKey + HyperswitchURL are all set. Without those,
    the service falls back to no-provider mode (paid subscribes
    return 503).
  - **Tests** : new TestSubscribe_PendingPaymentStateMachine in
    gate_test.go covers all five visible outcomes (free / paid+
    provider / paid+no-provider / first-trial / repeat-trial) with a
    fakePaymentProvider that records calls. Asserts on idempotency
    key = subscription.ID.String(), PSP call counts, and the
    Subscribe response shape (client_secret + payment_id surfaced).
    5/5 green, sqlite :memory:.

Phase 2 backlog (next session) :
  - `ProcessSubscriptionWebhook(ctx, payload)` — flip pending_payment
    → active on success / expired on failure, idempotent against
    replays.
  - Recovery endpoint `POST /api/v1/subscriptions/complete/:id` —
    return the existing client_secret to resume a stalled flow.
  - Reconciliation sweep for rows stuck in pending_payment past the
    webhook-arrival window (uses the new partial index from
    migration 986).
  - Distribution.checkEligibility explicit pending_payment branch
    (today it's already handled implicitly via the active/trialing
    filter).
  - E2E @critical : POST /subscribe → POST /distribution/submit
    asserts 403 with "complete payment" until webhook fires.

Backward compat : clients on the previous flow that called
/subscribe expecting an immediately-active row will now see
status=pending_payment + a client_secret. They must drive the PSP
confirm step before the row is granted feature access. The
v1.0.6.2 voided_subscriptions cleanup migration (980) handles
pre-existing fantôme rows.

go build ./... clean. Subscription + handlers test suites green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:02:00 +02:00

367 lines
11 KiB
Go

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