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)