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>
367 lines
11 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|