From 99763108a64f4f4bb1faea2ea8a5e7ec97f676d5 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 12 Nov 2026 13:59:59 +0000 Subject: [PATCH] Block Ultra checkout for users with an active paid subscription createCheckoutSession had no check for an existing subscription. An Ultra subscriber could log out, enter their own email in the purchase modal and get a fresh Stripe Checkout that accepts promotion codes. Paying would create a second subscription alongside the first. Now they're redirected to the dashboard (or login, if logged out) instead. Comped subscribers can still check out as before. Co-Authored-By: Claude Opus 5.5 --- app/Livewire/MobilePricing.php | 22 +++++++++++ tests/Feature/MobilePricingTest.php | 58 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/app/Livewire/MobilePricing.php b/app/Livewire/MobilePricing.php index 6fcf7e665..ad5f379bf 100644 --- a/app/Livewire/MobilePricing.php +++ b/app/Livewire/MobilePricing.php @@ -71,6 +71,18 @@ public function createCheckoutSession(?string $plan, ?User $user = null) return; } + if ($this->hasActivePaidSubscription($user)) { + Log::warning('Blocked checkout session for a user who already has an active paid subscription.', [ + 'user_id' => $user->id, + ]); + + $message = 'This account already has an active subscription. Manage it from your dashboard.'; + + return Auth::id() === $user->id + ? redirect(route('dashboard'))->with('message', $message) + : redirect(route('login'))->with('message', $message); + } + // Cancel any comped subscription before creating a new paid one if ($user->hasCompedSubscription()) { $user->subscription('default')->cancelNow(); @@ -220,6 +232,16 @@ public function upgradeSubscription(): mixed return redirect(route('dashboard'))->with('success', 'Your subscription has been upgraded to Ultra!'); } + /** + * Comped subscriptions are replaced at checkout, so only paid ones block a new checkout. + */ + private function hasActivePaidSubscription(User $user): bool + { + $subscription = $user->subscription('default'); + + return $subscription && $subscription->active() && ! $user->hasCompedSubscription(); + } + private function findOrCreateUser(string $email): User { Validator::validate(['email' => $email], [ diff --git a/tests/Feature/MobilePricingTest.php b/tests/Feature/MobilePricingTest.php index 5f27d9af2..5964c9be1 100644 --- a/tests/Feature/MobilePricingTest.php +++ b/tests/Feature/MobilePricingTest.php @@ -629,4 +629,62 @@ public function preview_upgrade_sets_preview_to_null_when_stripe_has_no_upcoming ->assertSet('upgradePreview', null) ->assertOk(); } + + #[Test] + public function ultra_subscriber_cannot_start_a_new_checkout_session() + { + $user = $this->createSubscribedUser(self::MAX_PRICE_ID); + Auth::login($user); + + Livewire::test(MobilePricing::class) + ->call('createCheckoutSession', 'max') + ->assertRedirect(route('dashboard')); + + $this->assertSame(1, $user->subscriptions()->count()); + } + + #[Test] + public function guest_cannot_start_a_checkout_session_for_an_email_with_an_active_subscription() + { + $user = $this->createSubscribedUser(self::MAX_PRICE_ID); + + Livewire::test(MobilePricing::class) + ->call('handlePurchaseRequest', [ + 'email' => $user->email, + 'plan' => 'max', + ]) + ->assertRedirect(route('login')); + + $this->assertSame(1, $user->subscriptions()->count()); + } + + #[Test] + public function pro_subscriber_cannot_bypass_the_upgrade_flow_with_a_new_checkout_session() + { + $user = $this->createSubscribedUser(self::PRO_PRICE_ID); + Auth::login($user); + + Livewire::test(MobilePricing::class) + ->call('createCheckoutSession', 'max') + ->assertRedirect(route('dashboard')); + } + + private function createSubscribedUser(string $priceId): User + { + $user = User::factory()->create(['stripe_id' => 'cus_'.uniqid()]); + + $subscription = Cashier::$subscriptionModel::factory() + ->for($user) + ->active() + ->create([ + 'stripe_price' => $priceId, + 'is_comped' => false, + ]); + + Cashier::$subscriptionItemModel::factory() + ->for($subscription, 'subscription') + ->create(['stripe_price' => $priceId]); + + return $user; + } }