From 7e26a8dd1f8a60cf54208adeacb6c3baa02601e1 Mon Sep 17 00:00:00 2001 From: senke Date: Mon, 27 Apr 2026 11:33:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(subscription):=20recovery=20endpoint=20+?= =?UTF-8?q?=20distribution=20gate=20(v1.0.9=20item=20G=20=E2=80=94=20Phase?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../internal/api/routes_subscription.go | 4 + .../core/distribution/eligibility_test.go | 99 +++++++++ .../internal/core/distribution/service.go | 11 +- .../core/subscription/recovery_test.go | 210 ++++++++++++++++++ .../internal/core/subscription/service.go | 130 +++++++++++ .../internal/handlers/distribution_handler.go | 6 + .../internal/handlers/subscription_handler.go | 49 ++++ 7 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 veza-backend-api/internal/core/distribution/eligibility_test.go create mode 100644 veza-backend-api/internal/core/subscription/recovery_test.go diff --git a/veza-backend-api/internal/api/routes_subscription.go b/veza-backend-api/internal/api/routes_subscription.go index 4b0c8f1fe..eb2cf8a1f 100644 --- a/veza-backend-api/internal/api/routes_subscription.go +++ b/veza-backend-api/internal/api/routes_subscription.go @@ -38,6 +38,10 @@ func (r *APIRouter) setupSubscriptionRoutes(router *gin.RouterGroup) { protected.GET("/me", handler.GetMySubscription) protected.POST("/subscribe", handler.Subscribe) + // v1.0.9 item G Phase 3: recovery endpoint for stalled + // pending_payment rows. Returns the existing payment intent's + // client_secret (idempotent on sub.ID). + protected.POST("/complete/:id", handler.Complete) protected.POST("/cancel", handler.CancelSubscription) protected.POST("/reactivate", handler.ReactivateSubscription) protected.PUT("/billing-cycle", handler.ChangeBillingCycle) diff --git a/veza-backend-api/internal/core/distribution/eligibility_test.go b/veza-backend-api/internal/core/distribution/eligibility_test.go new file mode 100644 index 000000000..3a50c996d --- /dev/null +++ b/veza-backend-api/internal/core/distribution/eligibility_test.go @@ -0,0 +1,99 @@ +package distribution + +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" + + "veza-backend-api/internal/core/subscription" +) + +// TestSubmit_EligibilityGate_PendingPayment locks down the v1.0.9 item G +// Phase 3 contract: a creator whose only subscription is in +// pending_payment must be rejected with ErrSubscriptionPendingPayment +// (distinct from ErrNotEligible) so the handler can surface the +// "complete payment" recovery hint instead of the generic "upgrade". +func TestSubmit_EligibilityGate_PendingPayment(t *testing.T) { + ctx := context.Background() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &subscription.Plan{}, + &subscription.UserSubscription{}, + &subscription.Invoice{}, + )) + + // Plan with HasDistribution so the user *would* be eligible if not + // for the pending_payment status — isolates the gate to the status + // branch alone. + plan := subscription.Plan{ + ID: uuid.New(), Name: subscription.PlanCreator, DisplayName: "Creator", + PriceMonthly: 999, + Currency: "USD", + HasDistribution: true, + IsActive: true, + } + require.NoError(t, db.Create(&plan).Error) + + creatorID := uuid.New() + now := time.Now() + require.NoError(t, db.Create(&subscription.UserSubscription{ + UserID: creatorID, PlanID: plan.ID, Status: subscription.StatusPendingPayment, + BillingCycle: subscription.BillingMonthly, + CurrentPeriodStart: now, CurrentPeriodEnd: now.AddDate(0, 1, 0), + }).Error) + + subSvc := subscription.NewService(db, zap.NewNop()) + distSvc := NewService(db, zap.NewNop(), subSvc) + + // Submit with a valid platform but irrelevant track — eligibility + // fires before track lookup, so the test doesn't need a tracks + // table or a real track row. + _, err = distSvc.Submit(ctx, creatorID, SubmitRequest{ + TrackID: uuid.New(), + Platforms: []Platform{PlatformSpotify}, + Metadata: DistributionMetadata{TrackTitle: "irrelevant", ArtistName: "irrelevant"}, + }) + require.Error(t, err) + assert.True(t, errors.Is(err, subscription.ErrSubscriptionPendingPayment), + "pending_payment must surface as ErrSubscriptionPendingPayment, got %v", err) +} + +// TestSubmit_EligibilityGate_NoSubscription verifies that a user with +// no subscription row at all gets ErrNotEligible (the upgrade-prompt +// path), not the recovery-prompt path. This is the dual of the +// pending_payment test above. +func TestSubmit_EligibilityGate_NoSubscription(t *testing.T) { + ctx := context.Background() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &subscription.Plan{}, + &subscription.UserSubscription{}, + &subscription.Invoice{}, + )) + + subSvc := subscription.NewService(db, zap.NewNop()) + distSvc := NewService(db, zap.NewNop(), subSvc) + + _, err = distSvc.Submit(ctx, uuid.New(), SubmitRequest{ + TrackID: uuid.New(), + Platforms: []Platform{PlatformSpotify}, + Metadata: DistributionMetadata{TrackTitle: "irrelevant", ArtistName: "irrelevant"}, + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrNotEligible), + "user with no subscription must get ErrNotEligible (upgrade hint), got %v", err) + assert.False(t, errors.Is(err, subscription.ErrSubscriptionPendingPayment), + "the no-sub path must NOT trigger the pending_payment branch") +} diff --git a/veza-backend-api/internal/core/distribution/service.go b/veza-backend-api/internal/core/distribution/service.go index 5e06da9e5..4e10dbe88 100644 --- a/veza-backend-api/internal/core/distribution/service.go +++ b/veza-backend-api/internal/core/distribution/service.go @@ -244,9 +244,16 @@ func (s *Service) checkEligibility(ctx context.Context, userID uuid.UUID) (bool, sub, err := s.subscriptionService.GetUserSubscription(ctx, userID) if err != nil { - // No subscription row: ineligible with no extra signal — handler - // surfaces the standard "Creator or Premium plan required" message. + // v1.0.9 item G Phase 3: a row in StatusPendingPayment doesn't + // match GetUserSubscription's active/trialing filter, so we + // arrive here with ErrNoActiveSubscription. Probe explicitly for + // pending_payment so the handler can surface the recovery hint + // ("complete your payment") instead of the generic "upgrade". if errors.Is(err, subscription.ErrNoActiveSubscription) { + if _, pendErr := s.subscriptionService.GetPendingPaymentSubscription(ctx, userID); pendErr == nil { + return false, subscription.ErrSubscriptionPendingPayment + } + // No active and no pending — vanilla "upgrade your plan" path. return false, nil } // v1.0.6.2: propagate ErrSubscriptionNoPayment so the handler can diff --git a/veza-backend-api/internal/core/subscription/recovery_test.go b/veza-backend-api/internal/core/subscription/recovery_test.go new file mode 100644 index 000000000..0f0a0c833 --- /dev/null +++ b/veza-backend-api/internal/core/subscription/recovery_test.go @@ -0,0 +1,210 @@ +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") + }) +} diff --git a/veza-backend-api/internal/core/subscription/service.go b/veza-backend-api/internal/core/subscription/service.go index a234063ca..32c4eb098 100644 --- a/veza-backend-api/internal/core/subscription/service.go +++ b/veza-backend-api/internal/core/subscription/service.go @@ -38,6 +38,20 @@ var ( // "payment provider not configured" so an env misconfiguration is // loud instead of silently giving away paid plans. ErrPaymentProviderRequired = errors.New("paid plan requires a configured payment provider") + // ErrSubscriptionPendingPayment (v1.0.9 item G Phase 3): a paid-plan + // row exists in StatusPendingPayment — the user opened the subscribe + // flow but the webhook hasn't confirmed (succeeded/failed) yet, or + // the flow stalled on the frontend before payment confirmation. + // Distinct from ErrNoActiveSubscription so the UX can prompt the + // user to RESUME the existing payment via POST /subscriptions/ + // complete/:id (which re-fetches the client_secret) instead of + // starting a fresh subscribe that would create a duplicate row. + ErrSubscriptionPendingPayment = errors.New("subscription has a pending payment — complete it to activate") + // ErrSubscriptionNotPending (v1.0.9 item G Phase 3): the + // CompletePendingPayment recovery endpoint was called against a + // subscription that is NOT in StatusPendingPayment. Surfaced as + // HTTP 409 so the frontend doesn't silently swap out an active row. + ErrSubscriptionNotPending = errors.New("subscription is not in pending_payment state") ) // PaymentProvider defines the interface for subscription payments. @@ -164,6 +178,122 @@ func (s *Service) hasEffectivePayment(ctx context.Context, sub *UserSubscription return count > 0 } +// GetPendingPaymentSubscription returns the most-recently-created +// subscription row for the user that is still in StatusPendingPayment, +// preloading Plan. Returns ErrNoActiveSubscription when no such row +// exists (callers chain after a GetUserSubscription miss to detect the +// "stalled payment" case). v1.0.9 item G Phase 3. +func (s *Service) GetPendingPaymentSubscription(ctx context.Context, userID uuid.UUID) (*UserSubscription, error) { + var sub UserSubscription + err := s.db.WithContext(ctx). + Preload("Plan"). + Where("user_id = ? AND status = ?", userID, StatusPendingPayment). + Order("created_at DESC"). + First(&sub).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNoActiveSubscription + } + return nil, fmt.Errorf("failed to get pending_payment subscription: %w", err) + } + return &sub, nil +} + +// CompletePendingPayment re-fetches the PSP client_secret for a +// subscription stuck in StatusPendingPayment so the frontend can drive +// the payment UI to completion. The PSP CreateSubscriptionPayment call +// is idempotent on the subscription ID (item G + item D pattern), so +// hitting this endpoint repeatedly returns the same payment intent +// rather than creating duplicates. +// +// Errors: +// - ErrSubscriptionNotFound: subID does not exist or does not belong to userID +// - ErrSubscriptionNotPending: subscription is NOT in pending_payment +// (already activated by webhook, expired, canceled, etc.) +// - ErrPaymentProviderRequired: HYPERSWITCH_ENABLED=false or provider missing +// +// Idempotency safety: the PSP returns the same payment_id for the same +// idempotency key (sub.ID.String()), so this endpoint is safe to retry +// from the frontend on transient network failures. +func (s *Service) CompletePendingPayment(ctx context.Context, userID, subscriptionID uuid.UUID) (*SubscribeResponse, error) { + var sub UserSubscription + if err := s.db.WithContext(ctx). + Preload("Plan"). + Where("id = ? AND user_id = ?", subscriptionID, userID). + First(&sub).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSubscriptionNotFound + } + return nil, fmt.Errorf("failed to get subscription: %w", err) + } + + if sub.Status != StatusPendingPayment { + return nil, ErrSubscriptionNotPending + } + + if s.paymentProvider == nil { + return nil, ErrPaymentProviderRequired + } + + // Resolve the amount from the latest invoice (Phase 1 created one + // with the same period bounds; using its row keeps amount + currency + // authoritative even if the plan price has changed since). + var inv Invoice + if err := s.db.WithContext(ctx). + Where("subscription_id = ?", sub.ID). + Order("created_at DESC"). + First(&inv).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // Defensive: a pending_payment row WITHOUT an invoice is a + // data integrity issue (Phase 1 always creates the pair). + // Fall back to plan pricing rather than 500-ing. + return nil, fmt.Errorf("subscription %s in pending_payment without invoice", sub.ID) + } + return nil, fmt.Errorf("failed to get invoice: %w", err) + } + + paymentID, clientSecret, psErr := s.paymentProvider.CreateSubscriptionPayment( + ctx, + sub.ID.String(), // idempotency key — same as Phase 1 so the PSP returns the existing intent + inv.AmountCents, + inv.Currency, + sub.ID.String(), + "", + map[string]string{ + "user_id": userID.String(), + "subscription_id": sub.ID.String(), + "plan": string(sub.Plan.Name), + "billing_cycle": string(sub.BillingCycle), + "recovery": "true", + }, + ) + if psErr != nil { + return nil, fmt.Errorf("failed to recover payment intent: %w", psErr) + } + + // Persist payment_id if it changed (defensive — idempotent calls + // should return the same id, but the PSP may have rotated for any + // reason and the webhook handler relies on this column to dispatch). + if inv.HyperswitchPaymentID != paymentID { + s.logger.Warn("CompletePendingPayment: PSP returned a different payment_id than recorded; updating invoice", + zap.String("subscription_id", sub.ID.String()), + zap.String("old_payment_id", inv.HyperswitchPaymentID), + zap.String("new_payment_id", paymentID)) + if err := s.db.WithContext(ctx). + Model(&Invoice{}). + Where("id = ?", inv.ID). + Updates(map[string]any{"hyperswitch_payment_id": paymentID}).Error; err != nil { + return nil, fmt.Errorf("failed to update invoice payment_id: %w", err) + } + } + + return &SubscribeResponse{ + Subscription: &sub, + ClientSecret: clientSecret, + PaymentID: paymentID, + }, nil +} + // GetUserSubscriptionHistory returns all subscriptions for a user (including canceled/expired) func (s *Service) GetUserSubscriptionHistory(ctx context.Context, userID uuid.UUID, limit, offset int) ([]UserSubscription, error) { if limit <= 0 || limit > 100 { diff --git a/veza-backend-api/internal/handlers/distribution_handler.go b/veza-backend-api/internal/handlers/distribution_handler.go index 875a098a6..cfbe4d4e6 100644 --- a/veza-backend-api/internal/handlers/distribution_handler.go +++ b/veza-backend-api/internal/handlers/distribution_handler.go @@ -52,6 +52,12 @@ func (h *DistributionHandler) Submit(c *gin.Context) { // linkage. Distinct from ErrNotEligible so the UX can tell // them to complete payment rather than upgrade. RespondWithAppError(c, apperrors.NewForbiddenError("Your subscription is not linked to a payment. Complete payment to enable distribution.")) + case errors.Is(err, subscription.ErrSubscriptionPendingPayment): + // v1.0.9 item G Phase 3: a pending_payment row exists. The + // frontend should call POST /api/v1/subscriptions/complete/:id + // to recover the client_secret and finish the original + // payment intent rather than start a new subscribe flow. + RespondWithAppError(c, apperrors.NewForbiddenError("Your subscription payment is pending. Complete the payment to enable distribution.")) case errors.Is(err, distribution.ErrTrackNotPublic): RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you")) case errors.Is(err, distribution.ErrAlreadyDistributed): diff --git a/veza-backend-api/internal/handlers/subscription_handler.go b/veza-backend-api/internal/handlers/subscription_handler.go index 87a43f218..aa362526a 100644 --- a/veza-backend-api/internal/handlers/subscription_handler.go +++ b/veza-backend-api/internal/handlers/subscription_handler.go @@ -127,6 +127,55 @@ func (h *SubscriptionHandler) Subscribe(c *gin.Context) { RespondSuccess(c, http.StatusCreated, resp) } +// Complete recovers the PSP client_secret for a subscription stuck in +// pending_payment so the frontend can drive the payment UI to +// completion. v1.0.9 item G Phase 3 — closes the recovery loop. +// +// Path: POST /api/v1/subscriptions/complete/:id +// Auth: required, ownership enforced server-side (sub.user_id = caller). +// Returns: 200 {subscription, client_secret, payment_id} on success. +// +// 404 if the subscription doesn't belong to the caller. +// 409 if the subscription is not in pending_payment. +// 503 if HYPERSWITCH_ENABLED=false. +// +// The PSP CreateSubscriptionPayment call is idempotent on sub.ID, so +// this endpoint is safe to retry from the frontend on transient +// network failures — the same payment intent is returned each time. +func (h *SubscriptionHandler) Complete(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + idStr := c.Param("id") + subID, err := uuid.Parse(idStr) + if err != nil { + RespondWithAppError(c, apperrors.NewValidationError("Invalid subscription id")) + return + } + + resp, err := h.service.CompletePendingPayment(c.Request.Context(), userID, subID) + if err != nil { + switch { + case errors.Is(err, subscription.ErrSubscriptionNotFound): + RespondWithAppError(c, apperrors.NewNotFoundError("Subscription")) + case errors.Is(err, subscription.ErrSubscriptionNotPending): + // 409 — the subscription has already transitioned out of + // pending_payment (webhook arrived, manual admin action, + // upgrade). Caller should refresh /me to see current state. + RespondWithAppError(c, apperrors.New(apperrors.ErrCodeConflict, "Subscription is not in pending_payment state")) + case errors.Is(err, subscription.ErrPaymentProviderRequired): + RespondWithAppError(c, apperrors.NewServiceUnavailableError("Payment provider not configured — payment recovery unavailable")) + default: + RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to recover payment", err)) + } + return + } + + RespondSuccess(c, http.StatusOK, resp) +} + // CancelSubscription cancels the user's subscription at end of period func (h *SubscriptionHandler) CancelSubscription(c *gin.Context) { userID, ok := GetUserIDUUID(c)