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(/