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
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>
210 lines
7.8 KiB
Go
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")
|
|
})
|
|
}
|