fix(stripe): prevent duplicate subscription checkouts (#11666)
This commit is contained in:
parent
8a03647a70
commit
ba30c64d7d
16 changed files with 1004 additions and 74 deletions
|
|
@ -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.
|
||||
|
|
|
|||
221
app/Actions/Stripe/CreateCheckoutSession.php
Normal file
221
app/Actions/Stripe/CreateCheckoutSession.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
app/Exceptions/CheckoutUnavailableException.php
Normal file
18
app/Exceptions/CheckoutUnavailableException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
338
tests/Feature/Subscription/CreateCheckoutSessionTest.php
Normal file
338
tests/Feature/Subscription/CreateCheckoutSessionTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
27
tests/Feature/TeamSeederTest.php
Normal file
27
tests/Feature/TeamSeederTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
48
tests/Feature/UserSeederTest.php
Normal file
48
tests/Feature/UserSeederTest.php
Normal 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]);
|
||||
Loading…
Reference in a new issue