From d3beeb2d000229868127400de2ac3867902414ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:52:41 +0100 Subject: [PATCH] fix(subscription): prevent duplicate subscriptions with updateOrCreate - Replace manual subscription create/update logic with updateOrCreate() and firstOrCreate() to eliminate race conditions - Add validation in PricingPlans to prevent subscribing if team already has active subscription - Improve error handling for missing team_id in customer.subscription.updated event - Add tests verifying subscriptions are updated rather than duplicated --- app/Jobs/StripeProcessJob.php | 51 ++++------- app/Livewire/Subscription/PricingPlans.php | 6 ++ .../Subscription/StripeProcessJobTest.php | 87 +++++++++++++++++++ 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f5d52f29c..3485ffe32 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -73,25 +73,15 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification('Old subscription activated for team: '.$teamId); - $subscription->update([ + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => true, 'stripe_past_due' => false, - ]); - } else { - // send_internal_notification('New subscription for team: '.$teamId); - Subscription::create([ - 'team_id' => $teamId, - 'stripe_subscription_id' => $subscriptionId, - 'stripe_customer_id' => $customerId, - 'stripe_invoice_paid' => true, - 'stripe_past_due' => false, - ]); - } + ] + ); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -227,18 +217,14 @@ public function handle(): void // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - $subscription = Subscription::where('team_id', $teamId)->first(); - if ($subscription) { - // send_internal_notification("Subscription already exists for team: {$teamId}"); - throw new \RuntimeException("Subscription already exists for team: {$teamId}"); - } else { - Subscription::create([ - 'team_id' => $teamId, + Subscription::updateOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } + ] + ); break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); @@ -254,20 +240,19 @@ public function handle(): void $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { if ($status === 'incomplete_expired') { - // send_internal_notification('Subscription incomplete expired'); throw new \RuntimeException('Subscription incomplete expired'); } - if ($teamId) { - $subscription = Subscription::create([ - 'team_id' => $teamId, + if (! $teamId) { + throw new \RuntimeException('No subscription and team id found'); + } + $subscription = Subscription::firstOrCreate( + ['team_id' => $teamId], + [ 'stripe_subscription_id' => $subscriptionId, 'stripe_customer_id' => $customerId, 'stripe_invoice_paid' => false, - ]); - } else { - // send_internal_notification('No subscription and team id found'); - throw new \RuntimeException('No subscription and team id found'); - } + ] + ); } $cancelAtPeriodEnd = data_get($data, 'cancel_at_period_end'); $feedback = data_get($data, 'cancellation_details.feedback'); diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 6b2d3fb36..6e1b85404 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -11,6 +11,12 @@ class PricingPlans extends Component { public function subscribeStripe($type) { + if (currentTeam()->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return; + } + Stripe::setApiKey(config('subscription.stripe_api_key')); $priceId = match ($type) { diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php index 95cff188a..0a93f858c 100644 --- a/tests/Feature/Subscription/StripeProcessJobTest.php +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -50,6 +50,93 @@ // Critical: stripe_invoice_paid must remain false — payment not yet confirmed expect($subscription->stripe_invoice_paid)->toBeFalsy(); }); + + test('created event updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => true, + ]); + + $event = [ + 'type' => 'customer.subscription.created', + 'data' => [ + 'object' => [ + 'customer' => 'cus_new_123', + 'id' => 'sub_new_123', + 'metadata' => [ + 'team_id' => $this->team->id, + 'user_id' => $this->user->id, + ], + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_new_123'); + expect($subscription->stripe_customer_id)->toBe('cus_new_123'); + }); +}); + +describe('checkout.session.completed', function () { + test('creates subscription for new team', function () { + Queue::fake(); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_123', + 'customer' => 'cus_checkout_123', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription)->not->toBeNull(); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); + + test('updates existing subscription instead of duplicating', function () { + Queue::fake(); + + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_subscription_id' => 'sub_old', + 'stripe_customer_id' => 'cus_old', + 'stripe_invoice_paid' => false, + ]); + + $event = [ + 'type' => 'checkout.session.completed', + 'data' => [ + 'object' => [ + 'client_reference_id' => $this->user->id.':'.$this->team->id, + 'subscription' => 'sub_checkout_new', + 'customer' => 'cus_checkout_new', + ], + ], + ]; + + $job = new StripeProcessJob($event); + $job->handle(); + + expect(Subscription::where('team_id', $this->team->id)->count())->toBe(1); + $subscription = Subscription::where('team_id', $this->team->id)->first(); + expect($subscription->stripe_subscription_id)->toBe('sub_checkout_new'); + expect($subscription->stripe_invoice_paid)->toBeTruthy(); + }); }); describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {