From ba30c64d7df802861a66f53a0c59e6b9a9018473 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 7 Sep 2026 20:25:06 +0200
Subject: [PATCH] fix(stripe): prevent duplicate subscription checkouts
(#11666)
---
DESIGN.md | 3 +-
app/Actions/Stripe/CreateCheckoutSession.php | 221 ++++++++++++
.../CheckoutUnavailableException.php | 18 +
app/Jobs/StripeProcessJob.php | 15 +-
app/Livewire/Subscription/Index.php | 38 +-
app/Livewire/Subscription/PricingPlans.php | 81 ++---
database/seeders/TeamSeeder.php | 6 +-
database/seeders/UserSeeder.php | 3 +-
resources/views/components/toast.blade.php | 11 +-
.../views/livewire/layout-popups.blade.php | 16 +-
.../CreateCheckoutSessionTest.php | 338 ++++++++++++++++++
.../Subscription/StripeProcessJobTest.php | 77 +++-
.../SubscriptionPricingPageTest.php | 127 +++++++
tests/Feature/TeamSeederTest.php | 27 ++
tests/Feature/ToastPositionTest.php | 49 +++
tests/Feature/UserSeederTest.php | 48 +++
16 files changed, 1004 insertions(+), 74 deletions(-)
create mode 100644 app/Actions/Stripe/CreateCheckoutSession.php
create mode 100644 app/Exceptions/CheckoutUnavailableException.php
create mode 100644 tests/Feature/Subscription/CreateCheckoutSessionTest.php
create mode 100644 tests/Feature/TeamSeederTest.php
create mode 100644 tests/Feature/UserSeederTest.php
diff --git a/DESIGN.md b/DESIGN.md
index 86550bd0f..11048ad5d 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -638,8 +638,9 @@ ### Toasts
- Reicon status tile for success, info, warning, danger, or default;
- title plus optional description;
- dismiss and copy-details actions;
-- up to four stacked notifications;
+- normally up to four stacked notifications, without evicting persistent notices;
- four-second dismissal, paused while hovered;
+- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button;
- support for all six screen positions and sanitized custom HTML.
Do not bring back the old oversized dark rectangle.
diff --git a/app/Actions/Stripe/CreateCheckoutSession.php b/app/Actions/Stripe/CreateCheckoutSession.php
new file mode 100644
index 000000000..42d14c0f2
--- /dev/null
+++ b/app/Actions/Stripe/CreateCheckoutSession.php
@@ -0,0 +1,221 @@
+stripe ??= app(StripeClient::class);
+ }
+
+ public static function lockKey(int $teamId): string
+ {
+ return "stripe-checkout:team:{$teamId}";
+ }
+
+ public function execute(Team $team, User $user, string $priceId): object
+ {
+ $lock = Cache::lock(self::lockKey($team->id), 30);
+
+ if (! $lock->get()) {
+ throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.');
+ }
+
+ $previousMaxNetworkRetries = Stripe::getMaxNetworkRetries();
+ Stripe::setMaxNetworkRetries(2);
+
+ try {
+ return $this->createOrReuseSession($team, $user, $priceId);
+ } finally {
+ Stripe::setMaxNetworkRetries($previousMaxNetworkRetries);
+ $lock->release();
+ }
+ }
+
+ private function createOrReuseSession(Team $team, User $user, string $priceId): object
+ {
+ $subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]);
+ $customerId = $subscription->stripe_customer_id;
+
+ if (! $customerId) {
+ $customer = $this->stripe->customers->create([
+ 'email' => $user->email,
+ 'metadata' => [
+ 'team_id' => $team->id,
+ ],
+ ], [
+ 'idempotency_key' => "coolify-team-{$team->id}-customer",
+ ]);
+ $customerId = $customer->id;
+ $subscription->stripe_customer_id = $customerId;
+ $subscription->save();
+
+ Log::info('Stripe customer assigned for subscription checkout.', [
+ 'team_id' => $team->id,
+ 'stripe_customer_id' => $customerId,
+ ]);
+ }
+
+ $blockingSubscription = null;
+ foreach ($this->stripe->subscriptions->all([
+ 'customer' => $customerId,
+ 'limit' => 10,
+ 'status' => 'all',
+ ])->autoPagingIterator() as $stripeSubscription) {
+ if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) {
+ $blockingSubscription = $stripeSubscription;
+ break;
+ }
+ }
+
+ $this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription);
+
+ $sessions = $this->stripe->checkout->sessions->all([
+ 'customer' => $customerId,
+ 'limit' => 10,
+ 'status' => 'open',
+ ]);
+ $subscriptionSessions = collect($sessions->data)->filter(
+ fn (object $session): bool => ($session->mode ?? null) === 'subscription'
+ );
+ $openSession = $subscriptionSessions->first(
+ fn (object $session): bool => ($session->status ?? null) === 'open'
+ );
+
+ if ($openSession) {
+ $lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id);
+ if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) {
+ Log::info('Reusing pending Stripe subscription checkout.', [
+ 'team_id' => $team->id,
+ 'stripe_customer_id' => $customerId,
+ 'stripe_checkout_session_id' => $openSession->id,
+ 'stripe_subscription_id' => $openSession->subscription ?? null,
+ ]);
+
+ return $openSession;
+ }
+
+ $this->stripe->checkout->sessions->expire($openSession->id);
+ }
+
+ $session = $this->stripe->checkout->sessions->create([
+ 'allow_promotion_codes' => true,
+ 'billing_address_collection' => 'required',
+ 'client_reference_id' => $user->id.':'.$team->id,
+ 'customer' => $customerId,
+ 'customer_update' => [
+ 'name' => 'auto',
+ 'address' => 'auto',
+ ],
+ 'line_items' => [[
+ 'price' => $priceId,
+ 'adjustable_quantity' => [
+ 'enabled' => true,
+ 'minimum' => 2,
+ ],
+ 'quantity' => 2,
+ ]],
+ 'tax_id_collection' => [
+ 'enabled' => true,
+ ],
+ 'automatic_tax' => [
+ 'enabled' => true,
+ ],
+ 'subscription_data' => [
+ 'metadata' => [
+ 'user_id' => $user->id,
+ 'team_id' => $team->id,
+ ],
+ ],
+ 'payment_method_collection' => 'if_required',
+ 'mode' => 'subscription',
+ 'expires_at' => now()->addMinutes(35)->timestamp,
+ 'success_url' => route('dashboard', ['success' => true]),
+ 'cancel_url' => route('subscription.index', ['cancelled' => true]),
+ ]);
+
+ Log::info('Stripe subscription checkout created.', [
+ 'team_id' => $team->id,
+ 'stripe_customer_id' => $customerId,
+ 'stripe_checkout_session_id' => $session->id,
+ 'stripe_subscription_id' => $session->subscription ?? null,
+ ]);
+
+ return $session;
+ }
+
+ private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void
+ {
+ if (! $blockingSubscription) {
+ return;
+ }
+
+ Log::warning('Stripe subscription checkout blocked by existing subscription.', [
+ 'team_id' => $team->id,
+ 'stripe_customer_id' => $customerId,
+ 'stripe_subscription_id' => $blockingSubscription->id,
+ 'stripe_subscription_status' => $blockingSubscription->status,
+ ]);
+
+ $portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true)
+ ? $this->billingPortalUrl($customerId)
+ : null;
+
+ throw new CheckoutUnavailableException(
+ $this->blockingSubscriptionMessage($blockingSubscription->status),
+ $portalUrl,
+ );
+ }
+
+ private function blockingSubscriptionMessage(string $status): string
+ {
+ return match ($status) {
+ 'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.",
+ 'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.",
+ 'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.",
+ 'paused' => "This team's subscription is paused. Resume it in the billing portal.",
+ default => 'Team already has an active subscription.',
+ };
+ }
+
+ private function billingPortalUrl(string $customerId): ?string
+ {
+ try {
+ $session = $this->stripe->billingPortal->sessions->create([
+ 'customer' => $customerId,
+ 'return_url' => route('subscription.show'),
+ ]);
+ } catch (Throwable) {
+ return null;
+ }
+
+ return is_string($session->url ?? null) ? $session->url : null;
+ }
+}
diff --git a/app/Exceptions/CheckoutUnavailableException.php b/app/Exceptions/CheckoutUnavailableException.php
new file mode 100644
index 000000000..329941dbc
--- /dev/null
+++ b/app/Exceptions/CheckoutUnavailableException.php
@@ -0,0 +1,18 @@
+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::updateOrCreate(
+ $subscription = Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@@ -83,6 +83,12 @@ public function handle(): void
'stripe_past_due' => false,
]
);
+ logger()->info('Stripe subscription checkout completed.', [
+ 'team_id' => $team->id,
+ 'stripe_customer_id' => $customerId,
+ 'stripe_checkout_session_id' => data_get($data, 'id'),
+ 'stripe_subscription_id' => $subscription->stripe_subscription_id,
+ ]);
break;
case 'invoice.paid':
$customerId = data_get($data, 'customer');
@@ -218,7 +224,7 @@ 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::updateOrCreate(
+ $subscription = Subscription::firstOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@@ -226,6 +232,11 @@ public function handle(): void
'stripe_invoice_paid' => false,
]
);
+ if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) {
+ $subscription->update(['stripe_subscription_id' => $subscriptionId]);
+ } elseif ($subscription->stripe_customer_id !== $customerId) {
+ throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}.");
+ }
break;
case 'customer.subscription.updated':
$teamId = data_get($data, 'metadata.team_id');
diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php
index 022f6fdee..31f2e9141 100644
--- a/app/Livewire/Subscription/Index.php
+++ b/app/Livewire/Subscription/Index.php
@@ -2,8 +2,11 @@
namespace App\Livewire\Subscription;
+use App\Actions\Stripe\UpdateSubscriptionQuantity;
+use App\Jobs\ServerLimitCheckJob;
use App\Models\InstanceSettings;
use App\Providers\RouteServiceProvider;
+use Illuminate\Support\Facades\Cache;
use Livewire\Component;
use Stripe\StripeClient;
@@ -49,12 +52,19 @@ public function stripeCustomerPortal()
return redirect($session->url);
}
- public function getStripeStatus()
+ public function getStripeStatus(): mixed
{
+ $team = currentTeam();
+ $user = auth()->user();
+ abort_unless($team && $user?->isAdminOfTeam($team->id), 403);
+
try {
- $subscription = currentTeam()->subscription;
+ $subscription = $team->subscription()->first();
+ if (! $subscription?->stripe_customer_id) {
+ return null;
+ }
$stripe = app(StripeClient::class);
- $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id);
+ $customer = $stripe->customers->retrieve($subscription->stripe_customer_id);
if ($customer) {
$subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]);
$currentTeam = currentTeam()->id ?? null;
@@ -65,6 +75,26 @@ public function getStripeStatus()
$subscription->update([
'stripe_subscription_id' => $foundSubscription->id,
]);
+ if ($status === 'active') {
+ $subscription->update([
+ 'stripe_invoice_paid' => true,
+ 'stripe_past_due' => false,
+ 'stripe_plan_id' => data_get($foundSubscription, 'items.data.0.price.id'),
+ 'stripe_cancel_at_period_end' => data_get($foundSubscription, 'cancel_at_period_end', false),
+ ]);
+ if (str(data_get($foundSubscription, 'items.data.0.price.lookup_key'))->contains('dynamic')) {
+ $quantity = max(
+ UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
+ min((int) data_get($foundSubscription, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
+ );
+ $team->update(['custom_server_limit' => $quantity]);
+ ServerLimitCheckJob::dispatch($team);
+ }
+ $team->unsetRelation('subscription');
+ Cache::forget('user:'.$user->id.':team:'.$team->id);
+
+ return redirect()->route('subscription.show');
+ }
if ($status === 'unpaid') {
$this->isUnpaid = true;
}
@@ -82,6 +112,8 @@ public function getStripeStatus()
} finally {
$this->loading = false;
}
+
+ return null;
}
public function render()
diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php
index 65966aea5..e53c5c677 100644
--- a/app/Livewire/Subscription/PricingPlans.php
+++ b/app/Livewire/Subscription/PricingPlans.php
@@ -2,22 +2,28 @@
namespace App\Livewire\Subscription;
-use Illuminate\Support\Facades\Auth;
+use App\Actions\Stripe\CreateCheckoutSession;
+use App\Exceptions\CheckoutUnavailableException;
use Livewire\Component;
-use Stripe\Checkout\Session;
-use Stripe\Stripe;
+use RuntimeException;
+use Stripe\Exception\ApiErrorException;
class PricingPlans extends Component
{
- public function subscribeStripe($type)
+ public function subscribeStripe(string $type): mixed
{
- if (currentTeam()->subscription?->stripe_invoice_paid) {
- $this->dispatch('error', 'Team already has an active subscription.');
+ $team = currentTeam();
+ $user = auth()->user();
- return;
+ if (! $team || ! $user?->isAdminOfTeam($team->id)) {
+ abort(403);
}
- Stripe::setApiKey(config('subscription.stripe_api_key'));
+ if ($team->subscription?->stripe_invoice_paid) {
+ $this->dispatch('error', 'Team already has an active subscription.');
+
+ return null;
+ }
$priceId = match ($type) {
'dynamic-monthly' => config('subscription.stripe_price_id_dynamic_monthly'),
@@ -28,48 +34,29 @@ public function subscribeStripe($type)
if (! $priceId) {
$this->dispatch('error', 'Price ID not found! Please contact the administrator.');
- return;
+ return null;
}
- $payload = [
- 'allow_promotion_codes' => true,
- 'billing_address_collection' => 'required',
- 'client_reference_id' => Auth::id().':'.currentTeam()->id,
- 'line_items' => [[
- 'price' => $priceId,
- 'adjustable_quantity' => [
- 'enabled' => true,
- 'minimum' => 2,
- ],
- 'quantity' => 2,
- ]],
- 'tax_id_collection' => [
- 'enabled' => true,
- ],
- 'automatic_tax' => [
- 'enabled' => true,
- ],
- 'subscription_data' => [
- 'metadata' => [
- 'user_id' => Auth::id(),
- 'team_id' => currentTeam()->id,
- ],
- ],
- 'payment_method_collection' => 'if_required',
- 'mode' => 'subscription',
- 'success_url' => route('dashboard', ['success' => true]),
- 'cancel_url' => route('subscription.index', ['cancelled' => true]),
- ];
+ try {
+ $session = app(CreateCheckoutSession::class)->execute($team, $user, $priceId);
+ } catch (ApiErrorException $exception) {
+ report($exception);
+ $this->dispatch('error', 'Unable to confirm checkout with Stripe. Please try again shortly.');
- $customer = currentTeam()->subscription?->stripe_customer_id ?? null;
- if ($customer) {
- $payload['customer'] = $customer;
- $payload['customer_update'] = [
- 'name' => 'auto',
- ];
- } else {
- $payload['customer_email'] = Auth::user()->email;
+ return null;
+ } catch (CheckoutUnavailableException $exception) {
+ $message = $exception->getMessage();
+ if ($exception->billingPortalUrl) {
+ $message .= ' Open billing portal';
+ }
+ $this->dispatch('error', $message);
+
+ return null;
+ } catch (RuntimeException $exception) {
+ report($exception);
+ $this->dispatch('error', 'Unable to start checkout. Please try again shortly.');
+
+ return null;
}
- $session = Session::create($payload);
return redirect($session->url, 303);
}
diff --git a/database/seeders/TeamSeeder.php b/database/seeders/TeamSeeder.php
index 67c5ec489..08426044c 100644
--- a/database/seeders/TeamSeeder.php
+++ b/database/seeders/TeamSeeder.php
@@ -10,14 +10,14 @@ class TeamSeeder extends Seeder
{
public function run(): void
{
- $normal_user_in_root_team = User::find(1);
+ $normal_user_in_root_team = User::where('email', 'test2@example.com')->firstOrFail();
$root_user_personal_team = Team::find(0);
$root_user_personal_team->description = 'The root team';
$root_user_personal_team->save();
$normal_user_in_root_team->teams()->attach($root_user_personal_team);
- $normal_user_not_in_root_team = User::find(2);
- $normal_user_in_root_team_personal_team = Team::find(1);
+ $normal_user_not_in_root_team = User::where('email', 'test3@example.com')->firstOrFail();
+ $normal_user_in_root_team_personal_team = $normal_user_in_root_team->teams()->where('personal_team', true)->wherePivot('role', 'owner')->firstOrFail();
$normal_user_not_in_root_team->teams()->attach($normal_user_in_root_team_personal_team, ['role' => 'admin']);
}
}
diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php
index 2ac615cc0..9f237dc3b 100644
--- a/database/seeders/UserSeeder.php
+++ b/database/seeders/UserSeeder.php
@@ -15,14 +15,13 @@ public function run(): void
'email' => 'test@example.com',
]);
User::factory()->create([
- 'id' => 1,
'name' => 'Normal User (but in root team)',
'email' => 'test2@example.com',
]);
User::factory()->create([
- 'id' => 2,
'name' => 'Normal User (not in root team)',
'email' => 'test3@example.com',
]);
+
}
}
diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php
index 8c3eef13e..7de075a2b 100644
--- a/resources/views/components/toast.blade.php
+++ b/resources/views/components/toast.blade.php
@@ -8,6 +8,7 @@
description: options.description ?? '',
position: options.position ?? 'bottom-right',
html: options.html ?? '',
+ persistent: options.persistent ?? false,
},
}));
} catch (error) {
@@ -29,14 +30,19 @@
type: event.detail.type,
html: event.detail.html ? window.sanitizeHTML(event.detail.html) : '',
timeout: null,
+ persistent: event.detail.persistent === true,
copied: false,
copiedTimeout: null,
};
this.toasts.unshift(toast);
if (this.toasts.length > 4) {
- const removed = this.toasts.pop();
- clearTimeout(removed?.timeout);
+ const index = this.toasts.findLastIndex(item => !item.persistent);
+ if (index !== -1) {
+ const [removed] = this.toasts.splice(index, 1);
+ clearTimeout(removed.timeout);
+ clearTimeout(removed.copiedTimeout);
+ }
}
this.$nextTick(() => {
@@ -49,6 +55,7 @@
},
scheduleToast(toast, delay = 2000) {
clearTimeout(toast.timeout);
+ if (toast.persistent) return;
toast.timeout = setTimeout(() => this.removeToast(toast.id), delay);
},
pauseToast(toast) {
diff --git a/resources/views/livewire/layout-popups.blade.php b/resources/views/livewire/layout-popups.blade.php
index 693c332d9..938b08f4c 100644
--- a/resources/views/livewire/layout-popups.blade.php
+++ b/resources/views/livewire/layout-popups.blade.php
@@ -212,17 +212,11 @@ class="h-9 cursor-pointer px-2 text-[12px] font-medium text-neutral-500 transiti
@endif
@if (request()->query->get('success'))
-
-
-
-
Welcome onboard! Your subscription has been
- activated. It could take a few seconds before it's fully active.
-
-
+
@endif
@if (currentTeam()->subscriptionPastOverDue())
diff --git a/tests/Feature/Subscription/CreateCheckoutSessionTest.php b/tests/Feature/Subscription/CreateCheckoutSessionTest.php
new file mode 100644
index 000000000..6cb4e712b
--- /dev/null
+++ b/tests/Feature/Subscription/CreateCheckoutSessionTest.php
@@ -0,0 +1,338 @@
+set('cache.default', 'array');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->stripe = Mockery::mock(StripeClient::class);
+ $this->checkoutSessions = Mockery::mock(SessionService::class);
+ $this->customers = Mockery::mock(CustomerService::class);
+ $this->stripeSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->stripe->checkout = (object) ['sessions' => $this->checkoutSessions];
+ $this->stripe->customers = $this->customers;
+ $this->stripe->subscriptions = $this->stripeSubscriptions;
+});
+
+function stripeCheckoutCollection(array $data, ?array $allPages = null): object
+{
+ $pages = $allPages ?? $data;
+
+ return new class($data, $pages)
+ {
+ public function __construct(
+ public array $data,
+ private array $allPages,
+ ) {}
+
+ public function autoPagingIterator(): iterable
+ {
+ yield from $this->allPages;
+ }
+ };
+}
+
+test('two near-simultaneous checkout requests reuse one open session', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+
+ $openSession = (object) ['id' => 'cs_open', 'url' => 'https://checkout.stripe.test/cs_open', 'status' => 'open', 'mode' => 'subscription', 'subscription' => null];
+
+ $this->stripeSubscriptions->shouldReceive('all')->twice()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->twice()->andReturn(stripeCheckoutCollection([]), stripeCheckoutCollection([$openSession]));
+ $this->checkoutSessions->shouldReceive('create')->once()->andReturn($openSession);
+ $this->checkoutSessions->shouldReceive('allLineItems')->with('cs_open')->once()->andReturn(stripeCheckoutCollection([(object) ['price' => (object) ['id' => 'price_monthly']]]));
+
+ $action = new CreateCheckoutSession($this->stripe);
+ $first = $action->execute($this->team, $this->user, 'price_monthly');
+ $second = $action->execute($this->team, $this->user, 'price_monthly');
+
+ expect($first->id)->toBe('cs_open')->and($second->id)->toBe('cs_open');
+});
+
+test('checkout session expiration includes a buffer above Stripe\'s 30-minute minimum', function () {
+ $this->freezeTime();
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('create')->once()
+ ->withArgs(function (array $payload): bool {
+ expect($payload['expires_at'])->toBeGreaterThanOrEqual(now()->addMinutes(35)->timestamp)
+ ->and($payload['expires_at'])->toBeLessThanOrEqual(now()->addHours(24)->timestamp);
+
+ return $payload['customer'] === 'cus_existing';
+ })
+ ->andReturn((object) ['id' => 'cs_new', 'url' => 'https://checkout.stripe.test/cs_new']);
+
+ (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+});
+
+test('separate checkout attempts let the SDK manage idempotency', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $session = (object) ['id' => 'cs_retry', 'url' => 'https://checkout.stripe.test/cs_retry'];
+ $payloads = [];
+ $originalRetries = Stripe::getMaxNetworkRetries();
+
+ $this->stripeSubscriptions->shouldReceive('all')->twice()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->twice()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('create')->twice()
+ ->withArgs(function (array $payload, array $options = []) use (&$payloads): bool {
+ expect($options)->not->toHaveKey('idempotency_key');
+ expect(Stripe::getMaxNetworkRetries())->toBe(2);
+ $payloads[] = $payload;
+
+ return $payload['customer'] === 'cus_existing';
+ })->andReturn($session);
+
+ $action = new CreateCheckoutSession($this->stripe);
+ $action->execute($this->team, $this->user, 'price_monthly');
+ $this->travel(5)->seconds();
+ $action->execute($this->team, $this->user, 'price_monthly');
+
+ expect($payloads)->toHaveCount(2)
+ ->and($payloads[1]['expires_at'])->toBeGreaterThan($payloads[0]['expires_at'])
+ ->and(Stripe::getMaxNetworkRetries())->toBe($originalRetries);
+});
+
+test('an existing active Stripe subscription blocks checkout', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing', 'stripe_subscription_id' => 'sub_active']);
+ $billingPortalSessions = Mockery::mock(BillingPortalSessionService::class);
+ $this->stripe->billingPortal = (object) ['sessions' => $billingPortalSessions];
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'sub_active', 'status' => 'active']]));
+ $billingPortalSessions->shouldNotReceive('create');
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ expect(fn () => (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly'))
+ ->toThrow(CheckoutUnavailableException::class, 'active subscription');
+});
+
+test('blocking Stripe subscriptions explain the payment state instead of calling every status active', function (string $status, string $expected) {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'sub_blocked', 'status' => $status]]));
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ try {
+ (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+ $this->fail('Expected checkout to be blocked.');
+ } catch (CheckoutUnavailableException $exception) {
+ expect($exception->getMessage())
+ ->toBe($expected)
+ ->not->toContain('active subscription');
+ }
+})->with([
+ 'incomplete' => ['incomplete', "This team's subscription payment is incomplete. Complete the payment in the billing portal."],
+ 'past_due' => ['past_due', "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal."],
+ 'unpaid' => ['unpaid', "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal."],
+ 'paused' => ['paused', "This team's subscription is paused. Resume it in the billing portal."],
+]);
+
+test('recoverable blocked subscriptions include a billing portal link', function (string $status) {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $billingPortalSessions = Mockery::mock(BillingPortalSessionService::class);
+ $this->stripe->billingPortal = (object) ['sessions' => $billingPortalSessions];
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'sub_blocked', 'status' => $status]]));
+ $billingPortalSessions->shouldReceive('create')->once()
+ ->withArgs(fn (array $payload): bool => $payload['customer'] === 'cus_existing' && $payload['return_url'] === route('subscription.show'))
+ ->andReturn((object) ['url' => 'https://billing.stripe.test/session']);
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ try {
+ (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+ $this->fail('Expected checkout to be blocked.');
+ } catch (CheckoutUnavailableException $exception) {
+ expect($exception->billingPortalUrl)
+ ->toBe('https://billing.stripe.test/session')
+ ->and($exception->getMessage())->not->toContain('active subscription');
+ }
+})->with(['incomplete', 'past_due', 'unpaid', 'paused']);
+
+test('a past due subscription still blocks checkout when the billing portal cannot be opened', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $billingPortalSessions = Mockery::mock(BillingPortalSessionService::class);
+ $this->stripe->billingPortal = (object) ['sessions' => $billingPortalSessions];
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'sub_past_due', 'status' => 'past_due']]));
+ $billingPortalSessions->shouldReceive('create')->once()->andThrow(new ApiConnectionException('Connection failed'));
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ try {
+ (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+ $this->fail('Expected checkout to be blocked.');
+ } catch (CheckoutUnavailableException $exception) {
+ expect($exception->getMessage())->toContain('past due')
+ ->and($exception->billingPortalUrl)->toBeNull();
+ }
+});
+
+test('a blocking Stripe subscription beyond the first page still blocks checkout', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+
+ $firstPage = collect(range(1, 10))
+ ->map(fn (int $i): object => (object) ['id' => "sub_canceled_{$i}", 'status' => 'canceled'])
+ ->all();
+ $laterPageActive = (object) ['id' => 'sub_active_later', 'status' => 'active'];
+
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(
+ stripeCheckoutCollection($firstPage, [...$firstPage, $laterPageActive])
+ );
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ expect(fn () => (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly'))
+ ->toThrow(CheckoutUnavailableException::class, 'active subscription');
+});
+
+test('checkout session lookup asks Stripe for open sessions only', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $openSession = (object) ['id' => 'cs_open', 'url' => 'https://checkout.stripe.test/cs_open', 'status' => 'open', 'mode' => 'subscription', 'subscription' => null];
+
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()
+ ->with(['customer' => 'cus_existing', 'limit' => 10, 'status' => 'open'])
+ ->andReturn(stripeCheckoutCollection([$openSession]));
+ $this->checkoutSessions->shouldReceive('allLineItems')->with('cs_open')->once()->andReturn(stripeCheckoutCollection([(object) ['price' => (object) ['id' => 'price_monthly']]]));
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ $session = (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+
+ expect($session->id)->toBe('cs_open');
+});
+
+test('an expired checkout session permits a new checkout', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $expired = (object) ['id' => 'cs_expired', 'status' => 'expired', 'mode' => 'subscription', 'subscription' => null];
+
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([$expired]));
+ $this->checkoutSessions->shouldReceive('create')->once()
+ ->withArgs(fn (array $payload, array $options = []): bool => $payload['customer'] === 'cus_existing' && ! isset($options['idempotency_key']))
+ ->andReturn((object) ['id' => 'cs_new', 'url' => 'https://checkout.stripe.test/cs_new']);
+
+ $session = (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+ expect($session->id)->toBe('cs_new');
+});
+
+test('checkout creates and persists one Stripe customer for later attempts', function () {
+ $this->customers->shouldReceive('create')->once()
+ ->withArgs(fn (array $payload, array $options): bool => $payload['metadata']['team_id'] === $this->team->id && $options['idempotency_key'] === 'coolify-team-'.$this->team->id.'-customer')
+ ->andReturn((object) ['id' => 'cus_new']);
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('create')->once()
+ ->withArgs(function (array $payload): bool {
+ expect($payload['automatic_tax']['enabled'])->toBeTrue()
+ ->and($payload['billing_address_collection'])->toBe('required')
+ ->and($payload['customer_update'])->toMatchArray([
+ 'name' => 'auto',
+ 'address' => 'auto',
+ ]);
+
+ return $payload['customer'] === 'cus_new';
+ })
+ ->andReturn((object) ['id' => 'cs_new', 'url' => 'https://checkout.stripe.test/cs_new']);
+
+ (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly');
+ expect($this->team->fresh()->subscription->stripe_customer_id)->toBe('cus_new');
+});
+
+test('a monthly subscription can move to yearly after Stripe confirms the old subscription ended', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly');
+ config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly');
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_customer_id' => 'cus_existing',
+ 'stripe_subscription_id' => 'sub_monthly_ended',
+ 'stripe_plan_id' => 'price_monthly',
+ 'stripe_invoice_paid' => false,
+ ]);
+
+ $this->customers->shouldNotReceive('create');
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([
+ (object) ['id' => 'sub_monthly_ended', 'status' => 'canceled'],
+ ]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('create')->once()
+ ->withArgs(fn (array $payload): bool => $payload['customer'] === 'cus_existing' && $payload['line_items'][0]['price'] === 'price_yearly')
+ ->andReturn((object) ['id' => 'cs_yearly', 'url' => 'https://checkout.stripe.test/cs_yearly']);
+
+ $session = (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_yearly');
+
+ expect($session->id)->toBe('cs_yearly')
+ ->and($this->team->fresh()->subscription->stripe_customer_id)->toBe('cus_existing');
+});
+
+test('the checkout lock blocks a concurrent request before it calls Stripe', function () {
+ $lock = Cache::lock(CreateCheckoutSession::lockKey($this->team->id), 30);
+ expect($lock->get())->toBeTrue();
+
+ $this->customers->shouldNotReceive('create');
+ $this->stripeSubscriptions->shouldNotReceive('all');
+ $this->checkoutSessions->shouldNotReceive('create');
+
+ try {
+ expect(fn () => (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly'))
+ ->toThrow(CheckoutUnavailableException::class, 'already being created');
+ } finally {
+ $lock->release();
+ }
+});
+
+test('a Stripe connection failure releases the lock without creating another session', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $originalRetries = Stripe::getMaxNetworkRetries();
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('create')->once()->andThrow(new ApiConnectionException('Connection failed'));
+
+ expect(fn () => (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_monthly'))
+ ->toThrow(ApiConnectionException::class);
+ expect(Stripe::getMaxNetworkRetries())->toBe($originalRetries);
+ $lock = Cache::lock(CreateCheckoutSession::lockKey($this->team->id), 30);
+ try {
+ expect($lock->get())->toBeTrue();
+ } finally {
+ $lock->release();
+ }
+});
+
+test('changing checkout price expires the old session before creating the selected plan', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'cs_monthly', 'mode' => 'subscription', 'status' => 'open']]));
+ $this->checkoutSessions->shouldReceive('allLineItems')->with('cs_monthly')->once()->andReturn(stripeCheckoutCollection([(object) ['price' => (object) ['id' => 'price_monthly']]]));
+ $this->checkoutSessions->shouldReceive('expire')->with('cs_monthly')->once()->globally()->ordered()->andReturn((object) ['status' => 'expired']);
+ $this->checkoutSessions->shouldReceive('create')->once()->globally()->ordered()
+ ->withArgs(fn (array $payload): bool => $payload['line_items'][0]['price'] === 'price_yearly')
+ ->andReturn((object) ['id' => 'cs_yearly']);
+ $session = (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_yearly');
+ expect($session->id)->toBe('cs_yearly');
+});
+
+test('failed session expiration never creates a second checkout', function () {
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_existing']);
+ $this->stripeSubscriptions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([]));
+ $this->checkoutSessions->shouldReceive('all')->once()->andReturn(stripeCheckoutCollection([(object) ['id' => 'cs_monthly', 'mode' => 'subscription', 'status' => 'open']]));
+ $this->checkoutSessions->shouldReceive('allLineItems')->once()->andReturn(stripeCheckoutCollection([(object) ['price' => (object) ['id' => 'price_monthly']]]));
+ $this->checkoutSessions->shouldReceive('expire')->once()->andThrow(new ApiConnectionException('Cannot confirm expiration'));
+ $this->checkoutSessions->shouldNotReceive('create');
+ expect(fn () => (new CreateCheckoutSession($this->stripe))->execute($this->team, $this->user, 'price_yearly'))
+ ->toThrow(ApiConnectionException::class);
+});
diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php
index 302edf9e2..f681ca4a5 100644
--- a/tests/Feature/Subscription/StripeProcessJobTest.php
+++ b/tests/Feature/Subscription/StripeProcessJobTest.php
@@ -55,9 +55,13 @@
expect($subscription->stripe_invoice_paid)->toBeFalsy();
});
- test('created event updates existing subscription instead of duplicating', function () {
+ test('created event cannot overwrite a different recorded subscription', function () {
Queue::fake();
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
+ Notification::fake();
+
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_old',
@@ -84,8 +88,46 @@
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');
+ expect($subscription->stripe_subscription_id)->toBe('sub_old');
+ expect($subscription->stripe_customer_id)->toBe('cus_old');
+ expect($subscription->stripe_invoice_paid)->toBeTruthy();
+
+ Notification::assertSentTo($rootTeam, GeneralNotification::class, function (GeneralNotification $notification) {
+ return str_contains($notification->message, 'StripeProcessJob error:')
+ && str_contains($notification->message, 'cus_old')
+ && str_contains($notification->message, 'cus_new_123');
+ });
+ });
+
+ test('created event rejects a pending record with a different stripe customer id', function () {
+ Queue::fake();
+
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
+ Notification::fake();
+
+ Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_customer_id' => 'cus_pending',
+ 'stripe_invoice_paid' => false,
+ ]);
+
+ (new StripeProcessJob(['type' => 'customer.subscription.created', 'data' => ['object' => [
+ 'id' => 'sub_other',
+ 'customer' => 'cus_other',
+ 'metadata' => ['team_id' => $this->team->id, 'user_id' => $this->user->id],
+ ]]]))->handle();
+
+ $subscription = $this->team->subscription()->first();
+ expect($subscription->stripe_subscription_id)->toBeNull()
+ ->and($subscription->stripe_customer_id)->toBe('cus_pending')
+ ->and($subscription->stripe_invoice_paid)->toBeFalsy();
+
+ Notification::assertSentTo($rootTeam, GeneralNotification::class, function (GeneralNotification $notification) {
+ return str_contains($notification->message, 'StripeProcessJob error:')
+ && str_contains($notification->message, 'cus_pending')
+ && str_contains($notification->message, 'cus_other');
+ });
});
});
@@ -338,3 +380,32 @@
]],
]);
});
+
+test('late repeated subscription created events preserve confirmed payment', function () {
+ Queue::fake();
+ $completed = ['type' => 'checkout.session.completed', 'data' => ['object' => [
+ 'client_reference_id' => $this->user->id.':'.$this->team->id,
+ 'subscription' => 'sub_paid', 'customer' => 'cus_paid',
+ ]]];
+ (new StripeProcessJob($completed))->handle();
+ $created = ['type' => 'customer.subscription.created', 'data' => ['object' => [
+ 'id' => 'sub_paid', 'customer' => 'cus_paid',
+ 'metadata' => ['team_id' => $this->team->id, 'user_id' => $this->user->id],
+ ]]];
+ foreach (range(1, 2) as $attempt) {
+ (new StripeProcessJob($created))->handle();
+ expect($this->team->subscription()->first()->stripe_invoice_paid)->toBeTruthy();
+ }
+ expect($this->team->subscription()->count())->toBe(1);
+});
+
+test('created event fills a pending customer record without granting access', function () {
+ Queue::fake();
+ Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_pending', 'stripe_invoice_paid' => false]);
+ (new StripeProcessJob(['type' => 'customer.subscription.created', 'data' => ['object' => [
+ 'id' => 'sub_pending', 'customer' => 'cus_pending',
+ 'metadata' => ['team_id' => $this->team->id, 'user_id' => $this->user->id],
+ ]]]))->handle();
+ expect($this->team->subscription()->first()->stripe_subscription_id)->toBe('sub_pending')
+ ->and($this->team->subscription()->first()->stripe_invoice_paid)->toBeFalsy();
+});
diff --git a/tests/Feature/Subscription/SubscriptionPricingPageTest.php b/tests/Feature/Subscription/SubscriptionPricingPageTest.php
index 98e0c9ad4..f411d6425 100644
--- a/tests/Feature/Subscription/SubscriptionPricingPageTest.php
+++ b/tests/Feature/Subscription/SubscriptionPricingPageTest.php
@@ -1,5 +1,8 @@
set('app.maintenance.store', 'array');
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
@@ -133,3 +143,120 @@
->and($html)->not->toContain(route('subscription.show'))
->and($html)->not->toContain(route('subscription.index'));
});
+
+test('Stripe API failures show an error without redirecting or retrying checkout', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly');
+ $this->mock(CreateCheckoutSession::class)
+ ->shouldReceive('execute')->once()
+ ->andThrow(new ApiConnectionException('Connection failed'));
+
+ Livewire::test(PricingPlans::class)
+ ->call('subscribeStripe', 'dynamic-monthly')
+ ->assertDispatched('error', 'Unable to confirm checkout with Stripe. Please try again shortly.')
+ ->assertNoRedirect();
+});
+
+test('expected checkout unavailable errors keep their user-facing messages', function (string $message) {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly');
+ Exceptions::fake();
+ $this->mock(CreateCheckoutSession::class)
+ ->shouldReceive('execute')->once()
+ ->andThrow(new CheckoutUnavailableException($message));
+
+ Livewire::test(PricingPlans::class)
+ ->call('subscribeStripe', 'dynamic-monthly')
+ ->assertDispatched('error', $message)
+ ->assertNoRedirect();
+
+ Exceptions::assertNothingReported();
+})->with([
+ 'lock' => 'A subscription checkout is already being created for this team.',
+ 'active' => 'Team already has an active subscription.',
+ 'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.",
+ 'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.",
+ 'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.",
+]);
+
+test('pricing plans links recoverable checkout blocks to the billing portal', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly');
+ $message = "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.";
+ $this->mock(CreateCheckoutSession::class)
+ ->shouldReceive('execute')->once()
+ ->andThrow(new CheckoutUnavailableException($message, 'https://billing.stripe.test/session'));
+
+ Livewire::test(PricingPlans::class)
+ ->call('subscribeStripe', 'dynamic-monthly')
+ ->assertDispatched(
+ 'error',
+ $message.' Open billing portal'
+ )
+ ->assertNoRedirect();
+});
+
+test('unexpected checkout RuntimeExceptions are reported without exposing internal messages', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly');
+ Exceptions::fake();
+ $this->mock(CreateCheckoutSession::class)
+ ->shouldReceive('execute')->once()
+ ->andThrow(new RuntimeException('SQLSTATE[HY000]: General error: 1 table subscriptions has no column named foo'));
+
+ Livewire::test(PricingPlans::class)
+ ->call('subscribeStripe', 'dynamic-monthly')
+ ->assertDispatched('error', 'Unable to start checkout. Please try again shortly.')
+ ->assertNoRedirect();
+
+ Exceptions::assertReported(RuntimeException::class);
+});
+
+test('subscription status check restores an active subscription after a missed webhook', function (int $quantity, int $expectedLimit, string $lookupKey) {
+ Queue::fake();
+ $this->team->update(['custom_server_limit' => 7]);
+ $subscription = Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_recover', 'stripe_invoice_paid' => false]);
+ $stripe = Mockery::mock(StripeClient::class);
+ $stripe->customers = Mockery::mock(CustomerService::class);
+ $stripe->subscriptions = Mockery::mock(SubscriptionService::class);
+ $stripe->customers->shouldReceive('retrieve')->with('cus_recover')->once()->andReturn((object) ['id' => 'cus_recover']);
+ $stripe->subscriptions->shouldReceive('all')->with(['customer' => 'cus_recover'])->once()->andReturn((object) ['data' => [(object) [
+ 'id' => 'sub_recovered', 'status' => 'active', 'metadata' => (object) ['team_id' => (string) $this->team->id],
+ 'cancel_at_period_end' => true, 'items' => (object) ['data' => [(object) ['quantity' => $quantity, 'price' => (object) ['id' => 'price_yearly', 'lookup_key' => $lookupKey]]]],
+ ]]]);
+ app()->instance(StripeClient::class, $stripe);
+
+ Livewire::test(Index::class)->call('getStripeStatus')->assertRedirect(route('subscription.show'));
+
+ expect((bool) $subscription->fresh()->stripe_invoice_paid)->toBeTrue()
+ ->and($subscription->fresh()->stripe_subscription_id)->toBe('sub_recovered')
+ ->and($subscription->fresh()->stripe_plan_id)->toBe('price_yearly')
+ ->and((bool) $subscription->fresh()->stripe_cancel_at_period_end)->toBeTrue()
+ ->and((bool) $subscription->fresh()->stripe_past_due)->toBeFalse()
+ ->and($this->team->fresh()->custom_server_limit)->toBe($expectedLimit);
+ if (str_contains($lookupKey, 'dynamic')) {
+ Queue::assertPushed(ServerLimitCheckJob::class);
+ } else {
+ Queue::assertNotPushed(ServerLimitCheckJob::class);
+ }
+})->with([[5, 5, 'dynamic_yearly'], [1, 2, 'dynamic_yearly'], [101, 100, 'dynamic_yearly'], [5, 7, 'legacy']]);
+
+test('subscription status check does not activate non-active or other-team subscriptions', function (string $status, bool $otherTeam) {
+ $subscription = Subscription::create(['team_id' => $this->team->id, 'stripe_customer_id' => 'cus_recover', 'stripe_invoice_paid' => false]);
+ $stripe = Mockery::mock(StripeClient::class);
+ $stripe->customers = Mockery::mock(CustomerService::class);
+ $stripe->subscriptions = Mockery::mock(SubscriptionService::class);
+ $stripe->customers->shouldReceive('retrieve')->once()->andReturn((object) ['id' => 'cus_recover']);
+ $stripe->subscriptions->shouldReceive('all')->once()->andReturn((object) ['data' => [(object) [
+ 'id' => 'sub_other', 'status' => $status, 'metadata' => (object) ['team_id' => (string) ($otherTeam ? $this->team->id + 100 : $this->team->id)],
+ ]]]);
+ app()->instance(StripeClient::class, $stripe);
+
+ Livewire::test(Index::class)->call('getStripeStatus')->assertNoRedirect();
+ expect((bool) $subscription->fresh()->stripe_invoice_paid)->toBeFalse();
+ if ($otherTeam) {
+ expect($subscription->fresh()->stripe_subscription_id)->toBeNull();
+ }
+})->with([['incomplete', false], ['unpaid', false], ['canceled', false], ['active', true]]);
+
+test('team members cannot synchronize billing status', function () {
+ $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
+ $this->user->unsetRelation('teams');
+ Livewire::test(Index::class)->call('getStripeStatus')->assertForbidden();
+});
diff --git a/tests/Feature/TeamSeederTest.php b/tests/Feature/TeamSeederTest.php
new file mode 100644
index 000000000..96cf3a19e
--- /dev/null
+++ b/tests/Feature/TeamSeederTest.php
@@ -0,0 +1,27 @@
+ 0]);
+ $unrelatedUser = User::factory()->create();
+ User::factory()->create(['id' => 0, 'email' => 'test@example.com']);
+ $rootTeamMember = User::factory()->create(['id' => 10, 'email' => 'test2@example.com']);
+ $otherUser = User::factory()->create(['id' => 11, 'email' => 'test3@example.com']);
+ $personalTeam = $rootTeamMember->teams()->firstOrFail();
+
+ $this->seed(TeamSeeder::class);
+
+ expect(Team::findOrFail(0)->description)->toBe('The root team')
+ ->and($rootTeamMember->teams()->whereKey(0)->exists())->toBeTrue()
+ ->and($otherUser->teams()->whereKey($personalTeam->id)->firstOrFail()->pivot->role)->toBe('admin')
+ ->and($otherUser->teams()->whereKey(0)->exists())->toBeFalse()
+ ->and($unrelatedUser->teams()->count())->toBe(1)
+ ->and($otherUser->teams()->whereKey($unrelatedUser->teams()->firstOrFail()->id)->exists())->toBeFalse();
+});
diff --git a/tests/Feature/ToastPositionTest.php b/tests/Feature/ToastPositionTest.php
index 6bcfc898e..d8b7e5879 100644
--- a/tests/Feature/ToastPositionTest.php
+++ b/tests/Feature/ToastPositionTest.php
@@ -1,5 +1,7 @@
toContain('x-show="toast.copied"')
->toContain("'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied");
});
+
+test('subscription success uses a persistent standard toast instead of a banner', function () {
+ $view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php'));
+ if ($view === false) {
+ throw new RuntimeException('Could not read layout popups view.');
+ }
+
+ if (preg_match("/@if \\(request\\(\\)->query->get\\('success'\\)\\)(.*?)@endif/s", $view, $match) !== 1 || ! isset($match[1])) {
+ throw new RuntimeException('Could not locate the subscription success block.');
+ }
+
+ expect($match[1])->toContain("window.toast('Welcome onboard!'")
+ ->toContain('persistent: true')
+ ->not->toContain('');
+});
+
+test('persistent toasts survive timers hover and new notifications until dismissed', function () {
+ $process = new Process(['node', '-e', <<<'JS'
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const source = fs.readFileSync(process.argv[1], 'utf8');
+const data = source.match(/ { timers.set(++timerId, callback); return timerId; };
+global.clearTimeout = id => timers.delete(id);
+const state = new Function(`return (${data})`)();
+state.$nextTick = callback => callback();
+state.addToast({ detail: { message: 'Persistent', persistent: true } });
+const persistent = state.toasts[0];
+assert.equal(timers.size, 0, 'persistent toast must not schedule dismissal');
+state.pauseToast(persistent);
+state.resumeToast(persistent);
+assert.equal(timers.size, 0, 'hover must not start a persistent toast timer');
+for (let i = 0; i < 5; i++) state.addToast({ detail: { message: 'Normal' } });
+assert(state.toasts.includes(persistent), 'new notifications must not evict persistent toast');
+assert(timers.size > 0, 'normal notifications still have timers');
+for (const callback of [...timers.values()]) callback();
+for (const callback of [...timers.values()]) callback();
+assert(state.toasts.includes(persistent), 'persistent toast survives elapsed timers');
+state.removeToast(persistent.id);
+for (const callback of [...timers.values()]) callback();
+assert(!state.toasts.includes(persistent), 'manual dismissal removes persistent toast');
+JS, resource_path('views/components/toast.blade.php')]);
+ $process->run();
+ expect($process->isSuccessful())->toBeTrue($process->getErrorOutput());
+});
diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php
new file mode 100644
index 000000000..ce7705453
--- /dev/null
+++ b/tests/Feature/UserSeederTest.php
@@ -0,0 +1,48 @@
+markTestSkipped('Set TEST_POSTGRES_SEEDER=1 to test PostgreSQL using the configured pgsql connection.');
+ }
+
+ $originalConnectionName = config('database.default');
+ config()->set('database.default', $connectionName);
+ $connection = (new User)->getConnection();
+ $connection->beginTransaction();
+
+ try {
+ // A temporary table isolates this test from existing development users and their sequence.
+ $connection->getSchemaBuilder()->create('users', function (Blueprint $table) {
+ $table->temporary();
+ $table->id();
+ $table->string('name')->nullable();
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->timestamp('email_verified_at')->nullable();
+ $table->rememberToken();
+ $table->timestamps();
+ });
+
+ User::withoutEvents(function () use ($existingUsers) {
+ User::factory()->count($existingUsers)->create();
+
+ $this->seed(UserSeeder::class);
+
+ expect(User::query()->whereIn('email', ['test@example.com', 'test2@example.com', 'test3@example.com'])->orderBy('id')->pluck('email', 'id')->all())->toBe([
+ 0 => 'test@example.com',
+ $existingUsers + 1 => 'test2@example.com',
+ $existingUsers + 2 => 'test3@example.com',
+ ]);
+
+ expect(User::factory()->create()->id)->toBe($existingUsers + 3)
+ ->and(User::factory()->create()->id)->toBe($existingUsers + 4);
+ });
+ } finally {
+ $connection->rollBack();
+ config()->set('database.default', $originalConnectionName);
+ }
+})->with(['testing', 'pgsql'])->with([0, 5]);