fix(stripe): prevent duplicate subscription checkouts (#11666)

This commit is contained in:
Andras Bacsai 2026-09-07 20:25:06 +02:00 committed by GitHub
parent 8a03647a70
commit ba30c64d7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1004 additions and 74 deletions

View file

@ -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.

View file

@ -0,0 +1,221 @@
<?php
namespace App\Actions\Stripe;
use App\Exceptions\CheckoutUnavailableException;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\StripeClient;
use Throwable;
class CreateCheckoutSession
{
private const BLOCKING_SUBSCRIPTION_STATUSES = [
'active',
'incomplete',
'past_due',
'paused',
'trialing',
'unpaid',
];
private const RECOVERABLE_SUBSCRIPTION_STATUSES = [
'incomplete',
'past_due',
'paused',
'unpaid',
];
public function __construct(private ?StripeClient $stripe = null)
{
$this->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;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class CheckoutUnavailableException extends Exception
{
public function __construct(
string $message = '',
public readonly ?string $billingPortalUrl = null,
int $code = 0,
?Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}

View file

@ -74,7 +74,7 @@ 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::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');

View file

@ -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()

View file

@ -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 .= ' <a href="'.e($exception->billingPortalUrl).'" target="_blank" rel="noopener noreferrer" class="underline">Open billing portal</a>';
}
$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);
}

View file

@ -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']);
}
}

View file

@ -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',
]);
}
}

View file

@ -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) {

View file

@ -212,17 +212,11 @@ class="h-9 cursor-pointer px-2 text-[12px] font-medium text-neutral-500 transiti
</x-banner>
@endif
@if (request()->query->get('success'))
<x-banner>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
<span><span class="font-bold text-green-500">Welcome onboard!</span> Your subscription has been
activated. It could take a few seconds before it's fully active.</span>
</div>
</x-banner>
<span x-init="$nextTick(() => window.toast('Welcome onboard!', {
type: 'success',
description: 'Your subscription has been activated. It could take a few seconds before it is fully active.',
persistent: true,
}))"></span>
@endif
@if (currentTeam()->subscriptionPastOverDue())
<x-banner :closable=false>

View file

@ -0,0 +1,338 @@
<?php
use App\Actions\Stripe\CreateCheckoutSession;
use App\Exceptions\CheckoutUnavailableException;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Stripe\Exception\ApiConnectionException;
use Stripe\Service\BillingPortal\SessionService as BillingPortalSessionService;
use Stripe\Service\Checkout\SessionService;
use Stripe\Service\CustomerService;
use Stripe\Service\SubscriptionService;
use Stripe\Stripe;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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);
});

View file

@ -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();
});

View file

@ -1,5 +1,8 @@
<?php
use App\Actions\Stripe\CreateCheckoutSession;
use App\Exceptions\CheckoutUnavailableException;
use App\Jobs\ServerLimitCheckJob;
use App\Livewire\Subscription\Index;
use App\Livewire\Subscription\PricingPlans;
use App\Models\InstanceSettings;
@ -7,12 +10,19 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Stripe\Exception\ApiConnectionException;
use Stripe\Service\CustomerService;
use Stripe\Service\SubscriptionService;
use Stripe\StripeClient;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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.' <a href="https://billing.stripe.test/session" target="_blank" rel="noopener noreferrer" class="underline">Open billing portal</a>'
)
->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();
});

View file

@ -0,0 +1,27 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Database\Seeders\TeamSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('assigns seeded memberships without assuming normal user or personal team IDs', function () {
InstanceSettings::create(['id' => 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();
});

View file

@ -1,5 +1,7 @@
<?php
use Symfony\Component\Process\Process;
test('toast messages default to the bottom right while supporting position overrides', function () {
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
@ -24,3 +26,50 @@
->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('<x-banner>');
});
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(/<ul x-data="([\s\S]*?)" @toast-show/)[1];
const timers = new Map();
let timerId = 0;
global.setTimeout = callback => { 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());
});

View file

@ -0,0 +1,48 @@
<?php
use App\Models\User;
use Database\Seeders\UserSeeder;
use Illuminate\Database\Schema\Blueprint;
it('preserves the root ID and lets the database generate normal user IDs', function (string $connectionName, int $existingUsers) {
if ($connectionName === 'pgsql' && ! getenv('TEST_POSTGRES_SEEDER')) {
$this->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]);