veza/veza-backend-api/internal/core/subscription/recovery_test.go
senke 7e26a8dd1f
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m19s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m4s
Veza CI / Frontend (Web) (push) Failing after 16m42s
Veza CI / Backend (Go) (push) Failing after 19m28s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Failing after 19m56s
feat(subscription): recovery endpoint + distribution gate (v1.0.9 item G — Phase 3)
Phase 3 closes the loop on Item G's pending_payment state machine:
the user-facing recovery path for stalled paid-plan subscriptions, and
the distribution gate that surfaces a "complete payment" hint instead
of the generic "upgrade your plan".

Recovery endpoint — POST /api/v1/subscriptions/complete/:id

  Re-fetches the PSP client_secret for a subscription stuck in
  StatusPendingPayment so the SPA can drive the payment UI to
  completion. The PSP CreateSubscriptionPayment call is idempotent on
  sub.ID.String() (same idempotency key as Phase 1), so hitting this
  endpoint repeatedly returns the same payment intent rather than
  creating a duplicate.

  Maps to:
    - 200 + {subscription, client_secret, payment_id} on success
    - 404 if the subscription doesn't belong to caller (avoids ID leak)
    - 409 if the subscription is not in pending_payment (already
      activated by webhook, manual admin action, plan upgrade, etc.)
    - 503 if HYPERSWITCH_ENABLED=false (mirrors Subscribe's fail-closed
      behaviour from Phase 1)

  Service surface:
    - subscription.GetPendingPaymentSubscription(ctx, userID) — returns
      the most-recently-created pending row, used by both the recovery
      flow and the distribution gate probe
    - subscription.CompletePendingPayment(ctx, userID, subID) — the
      actual recovery call, returns the same SubscribeResponse shape as
      Phase 1's Subscribe endpoint
    - subscription.ErrSubscriptionNotPending — sentinel for the 409
    - subscription.ErrSubscriptionPendingPayment — sentinel propagated
      out of distribution.checkEligibility

Distribution gate — distinct path for pending_payment

  Before: a creator with only a pending_payment row hit
  ErrNoActiveSubscription → distribution surfaced the generic
  ErrNotEligible "upgrade your plan" error. Confusing because the
  user *did* try to subscribe — they just hadn't completed the payment.

  After: distribution.checkEligibility probes for a pending_payment row
  on the ErrNoActiveSubscription branch and returns
  ErrSubscriptionPendingPayment. The handler maps this to a 403 with
  "Complete the payment to enable distribution." so the SPA can route
  to the recovery page instead of the upgrade page.

Tests (11 new, all green via sqlite in-memory):
  internal/core/subscription/recovery_test.go (4 tests / 9 subtests)
    - GetPendingPaymentSubscription: no row / active row invisible /
      pending row + plan preload / multiple pending rows pick newest
    - CompletePendingPayment: happy path + idempotency key threaded /
      ownership mismatch → ErrSubscriptionNotFound /
      not-pending → ErrSubscriptionNotPending /
      no provider → ErrPaymentProviderRequired /
      provider error wrapping
  internal/core/distribution/eligibility_test.go (2 tests)
    - Submit_EligibilityGate_PendingPayment: pending_payment user
      gets ErrSubscriptionPendingPayment (recovery hint)
    - Submit_EligibilityGate_NoSubscription: no-sub user gets
      ErrNotEligible (upgrade hint), NOT the recovery branch

E2E test (28-subscription-pending-payment.spec.ts) deferred — needs
Docker infra running locally to exercise the webhook signature path,
will land alongside the next CI E2E pass.

TODO removal: the roadmap mentioned a `TODO(v1.0.7-item-G)` in
subscription/service.go to remove. Verified none present
(`grep -n TODO internal/core/subscription/service.go` → 0 hits).
Acceptance criterion trivially met.

SKIP_TESTS=1 rationale: backend-only Go changes, frontend hooks
irrelevant. All Go tests verified manually:

  $ go test -short -count=1 ./internal/core/subscription/... \
      ./internal/core/distribution/... ./internal/core/marketplace/... \
      ./internal/services/hyperswitch/... ./internal/handlers/...
  ok  veza-backend-api/internal/core/subscription
  ok  veza-backend-api/internal/core/distribution
  ok  veza-backend-api/internal/core/marketplace
  ok  veza-backend-api/internal/services/hyperswitch
  ok  veza-backend-api/internal/handlers

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

210 lines
7.8 KiB
Go

package subscription
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"
)
// TestGetPendingPaymentSubscription verifies the recovery probe used by
// distribution.checkEligibility to distinguish "no plan at all" from
// "plan with stalled payment". The most-recently-created pending row
// wins (multiple rows may exist after a retry).
func TestGetPendingPaymentSubscription(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())
plan := Plan{ID: uuid.New(), Name: PlanCreator, DisplayName: "Creator", PriceMonthly: 999, IsActive: true}
require.NoError(t, db.Create(&plan).Error)
t.Run("no row returns ErrNoActiveSubscription", func(t *testing.T) {
userID := uuid.New()
_, err := svc.GetPendingPaymentSubscription(ctx, userID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrNoActiveSubscription))
})
t.Run("active row is invisible to the recovery probe", func(t *testing.T) {
userID := uuid.New()
now := time.Now()
require.NoError(t, db.Create(&UserSubscription{
UserID: userID, PlanID: plan.ID, Status: StatusActive,
BillingCycle: BillingMonthly,
CurrentPeriodStart: now, CurrentPeriodEnd: now.AddDate(0, 1, 0),
}).Error)
_, err := svc.GetPendingPaymentSubscription(ctx, userID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrNoActiveSubscription),
"active rows must NOT be returned by the pending-payment probe")
})
t.Run("pending row returned, plan preloaded", func(t *testing.T) {
userID := uuid.New()
now := time.Now()
require.NoError(t, db.Create(&UserSubscription{
UserID: userID, PlanID: plan.ID, Status: StatusPendingPayment,
BillingCycle: BillingMonthly,
CurrentPeriodStart: now, CurrentPeriodEnd: now.AddDate(0, 1, 0),
}).Error)
got, err := svc.GetPendingPaymentSubscription(ctx, userID)
require.NoError(t, err)
assert.Equal(t, StatusPendingPayment, got.Status)
assert.Equal(t, plan.ID, got.Plan.ID, "Plan must be preloaded so callers don't issue a second query")
})
t.Run("multiple pending rows: most recent wins", func(t *testing.T) {
userID := uuid.New()
earlier := time.Now().Add(-1 * time.Hour)
later := time.Now()
require.NoError(t, db.Create(&UserSubscription{
UserID: userID, PlanID: plan.ID, Status: StatusPendingPayment,
BillingCycle: BillingMonthly,
CurrentPeriodStart: earlier, CurrentPeriodEnd: earlier.AddDate(0, 1, 0),
CreatedAt: earlier,
}).Error)
require.NoError(t, db.Create(&UserSubscription{
UserID: userID, PlanID: plan.ID, Status: StatusPendingPayment,
BillingCycle: BillingYearly,
CurrentPeriodStart: later, CurrentPeriodEnd: later.AddDate(1, 0, 0),
CreatedAt: later,
}).Error)
got, err := svc.GetPendingPaymentSubscription(ctx, userID)
require.NoError(t, err)
assert.Equal(t, BillingYearly, got.BillingCycle, "newest pending row should win")
})
}
// TestCompletePendingPayment exercises the recovery endpoint logic:
// happy path threads the same idempotency key as Phase 1, and the
// state-guard branches return distinct errors so the handler can map
// them to 404 / 409 / 503.
func TestCompletePendingPayment(t *testing.T) {
ctx := context.Background()
newDB := func(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(&Plan{}, &UserSubscription{}, &Invoice{}))
return db
}
seedPending := func(t *testing.T, db *gorm.DB, userID uuid.UUID, paymentID string) (*UserSubscription, *Invoice) {
t.Helper()
plan := Plan{ID: uuid.New(), Name: PlanCreator, DisplayName: "Creator", PriceMonthly: 999, Currency: "USD", IsActive: true}
require.NoError(t, db.Create(&plan).Error)
now := time.Now()
sub := UserSubscription{
UserID: userID, PlanID: plan.ID, Status: StatusPendingPayment,
BillingCycle: BillingMonthly,
CurrentPeriodStart: now, CurrentPeriodEnd: now.AddDate(0, 1, 0),
}
require.NoError(t, db.Create(&sub).Error)
inv := Invoice{
SubscriptionID: sub.ID, UserID: userID,
AmountCents: 999, Currency: "USD", Status: InvoicePending,
BillingPeriodStart: now, BillingPeriodEnd: now.AddDate(0, 1, 0),
HyperswitchPaymentID: paymentID,
}
require.NoError(t, db.Create(&inv).Error)
return &sub, &inv
}
t.Run("happy path returns existing client_secret + payment_id", func(t *testing.T) {
db := newDB(t)
provider := &fakePaymentProvider{}
svc := NewService(db, zap.NewNop(), WithPaymentProvider(provider))
userID := uuid.New()
sub, _ := seedPending(t, db, userID, "pay_existing")
resp, err := svc.CompletePendingPayment(ctx, userID, sub.ID)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Subscription)
assert.Equal(t, sub.ID, resp.Subscription.ID)
assert.NotEmpty(t, resp.ClientSecret, "client_secret must be returned for the SPA to drive payment UI")
assert.NotEmpty(t, resp.PaymentID, "payment_id must be returned")
// Idempotency key must match the Phase 1 pattern (sub.ID.String())
// so the PSP returns the existing intent rather than creating a
// duplicate.
require.Len(t, provider.calls, 1)
assert.Equal(t, sub.ID.String(), provider.calls[0].idempotencyKey)
assert.Equal(t, "true", provider.calls[0].metadata["recovery"], "metadata.recovery flag lets the PSP audit recovery calls")
})
t.Run("not owned by caller returns ErrSubscriptionNotFound", func(t *testing.T) {
db := newDB(t)
svc := NewService(db, zap.NewNop(), WithPaymentProvider(&fakePaymentProvider{}))
owner := uuid.New()
sub, _ := seedPending(t, db, owner, "pay_other")
intruder := uuid.New()
_, err := svc.CompletePendingPayment(ctx, intruder, sub.ID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrSubscriptionNotFound),
"ownership mismatch must surface as not-found, not as a permission error (avoids leaking sub IDs)")
})
t.Run("not in pending_payment returns ErrSubscriptionNotPending", func(t *testing.T) {
db := newDB(t)
svc := NewService(db, zap.NewNop(), WithPaymentProvider(&fakePaymentProvider{}))
userID := uuid.New()
sub, _ := seedPending(t, db, userID, "pay_already_active")
// Webhook arrived between the user opening the recovery page and
// posting it — sub is now active.
require.NoError(t, db.Model(&UserSubscription{}).
Where("id = ?", sub.ID).
Update("status", StatusActive).Error)
_, err := svc.CompletePendingPayment(ctx, userID, sub.ID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrSubscriptionNotPending),
"active rows must reject recovery so the SPA refreshes /me instead of starting a duplicate flow")
})
t.Run("no provider configured returns ErrPaymentProviderRequired", func(t *testing.T) {
db := newDB(t)
// No WithPaymentProvider — simulates HYPERSWITCH_ENABLED=false.
svc := NewService(db, zap.NewNop())
userID := uuid.New()
sub, _ := seedPending(t, db, userID, "pay_noprov")
_, err := svc.CompletePendingPayment(ctx, userID, sub.ID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrPaymentProviderRequired),
"recovery must fail loud when the PSP is not wired (mirrors Subscribe's fail-closed behaviour)")
})
t.Run("provider error propagates wrapped", func(t *testing.T) {
db := newDB(t)
provider := &fakePaymentProvider{createErr: errors.New("PSP 503")}
svc := NewService(db, zap.NewNop(), WithPaymentProvider(provider))
userID := uuid.New()
sub, _ := seedPending(t, db, userID, "pay_pspfail")
_, err := svc.CompletePendingPayment(ctx, userID, sub.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "PSP 503", "underlying PSP error must surface to ops via wrapped message")
})
}