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