diff --git a/app/Actions/Stripe/CreateCheckoutSession.php b/app/Actions/Stripe/CreateCheckoutSession.php index 42d14c0f25..dc0c9f72ba 100644 --- a/app/Actions/Stripe/CreateCheckoutSession.php +++ b/app/Actions/Stripe/CreateCheckoutSession.php @@ -157,6 +157,12 @@ class CreateCheckoutSession 'payment_method_collection' => 'if_required', 'mode' => 'subscription', 'expires_at' => now()->addMinutes(35)->timestamp, + 'after_expiration' => [ + 'recovery' => [ + 'enabled' => true, + 'allow_promotion_codes' => false, + ], + ], 'success_url' => route('dashboard', ['success' => true]), 'cancel_url' => route('subscription.index', ['cancelled' => true]), ]); diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 9e4e9f300b..d0d11cee65 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -96,6 +96,38 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_subscription_id' => $subscription->stripe_subscription_id, ]); break; + case 'checkout.session.expired': + $clientReferenceId = data_get($data, 'client_reference_id'); + if (! isCloud() || data_get($data, 'mode') !== 'subscription' || ! is_string($clientReferenceId)) { + break; + } + $userId = Str::before($clientReferenceId, ':'); + $teamId = Str::after($clientReferenceId, ':'); + $team = Team::find($teamId); + $user = $team?->members->where('id', $userId)->first(); + if (! $user?->isAdmin()) { + break; + } + // Atomic claim: one reminder per team per 7 days, also when Stripe delivers several expired sessions at once. + $claimed = Subscription::query() + ->where('team_id', $team->id) + ->where('stripe_invoice_paid', false) + ->where(fn ($query) => $query->whereNull('stripe_checkout_reminder_sent_at') + ->orWhere('stripe_checkout_reminder_sent_at', '<', now()->subDays(7))) + ->update(['stripe_checkout_reminder_sent_at' => now()]); + if ($claimed === 0) { + break; + } + $recoveryUrl = data_get($data, 'after_expiration.recovery.url'); + SubscriptionCheckoutReminderJob::dispatch( + $user, + is_string($recoveryUrl) && $recoveryUrl !== '' ? $recoveryUrl : route('subscription.index'), + ); + logger()->info('Stripe subscription checkout reminder queued.', [ + 'team_id' => $team->id, + 'stripe_checkout_session_id' => data_get($data, 'id'), + ]); + break; case 'invoice.paid': $customerId = data_get($data, 'customer'); $invoiceAmount = data_get($data, 'amount_paid', 0); diff --git a/app/Jobs/SubscriptionCheckoutReminderJob.php b/app/Jobs/SubscriptionCheckoutReminderJob.php new file mode 100644 index 0000000000..38ebe45d34 --- /dev/null +++ b/app/Jobs/SubscriptionCheckoutReminderJob.php @@ -0,0 +1,32 @@ +onQueue('high'); + } + + public function handle(): void + { + $mail = new MailMessage; + $mail->view('emails.subscription-checkout-reminder', [ + 'checkoutUrl' => $this->checkoutUrl, + ]); + $mail->subject('Coolify: Finish subscribing to Coolify Cloud'); + send_user_an_email($mail, $this->user->email); + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 35df71858e..7fc19356b9 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -28,6 +28,7 @@ class Subscription extends Model return [ 'stripe_refunded_at' => 'datetime', 'ended_at' => 'datetime', + 'stripe_checkout_reminder_sent_at' => 'datetime', ]; } diff --git a/database/migrations/2026_10_29_221743_add_checkout_reminder_sent_at_to_subscriptions_table.php b/database/migrations/2026_10_29_221743_add_checkout_reminder_sent_at_to_subscriptions_table.php new file mode 100644 index 0000000000..fe794607b7 --- /dev/null +++ b/database/migrations/2026_10_29_221743_add_checkout_reminder_sent_at_to_subscriptions_table.php @@ -0,0 +1,22 @@ +timestamp('stripe_checkout_reminder_sent_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn('stripe_checkout_reminder_sent_at'); + }); + } +}; diff --git a/resources/views/emails/subscription-checkout-reminder.blade.php b/resources/views/emails/subscription-checkout-reminder.blade.php new file mode 100644 index 0000000000..dd4fb20e2c --- /dev/null +++ b/resources/views/emails/subscription-checkout-reminder.blade.php @@ -0,0 +1,7 @@ + +You started subscribing to Coolify Cloud but didn't finish. + +Pick up where you left off: [Finish subscribing]({{ $checkoutUrl }}) + +This is a one-off reminder about this checkout. If you do not want to subscribe, you can ignore this email. + diff --git a/tests/Feature/Subscription/CreateCheckoutSessionTest.php b/tests/Feature/Subscription/CreateCheckoutSessionTest.php index 6cb4e712b4..157d754e9c 100644 --- a/tests/Feature/Subscription/CreateCheckoutSessionTest.php +++ b/tests/Feature/Subscription/CreateCheckoutSessionTest.php @@ -77,7 +77,8 @@ test('checkout session expiration includes a buffer above Stripe\'s 30-minute mi $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); + ->and($payload['expires_at'])->toBeLessThanOrEqual(now()->addHours(24)->timestamp) + ->and($payload['after_expiration'])->toBe(['recovery' => ['enabled' => true, 'allow_promotion_codes' => false]]); return $payload['customer'] === 'cus_existing'; }) diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php index c950a4592b..02c12439dc 100644 --- a/tests/Feature/Subscription/StripeProcessJobTest.php +++ b/tests/Feature/Subscription/StripeProcessJobTest.php @@ -2,6 +2,7 @@ use App\Jobs\ServerLimitCheckJob; use App\Jobs\StripeProcessJob; +use App\Jobs\SubscriptionCheckoutReminderJob; use App\Jobs\SubscriptionInvoiceFailedJob; use App\Jobs\VerifyStripeSubscriptionStatusJob; use App\Models\Server; @@ -13,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; use Stripe\Customer; +use Stripe\Event; use Stripe\PaymentIntent; use Stripe\Service\CustomerService; use Stripe\Service\PaymentIntentService; @@ -493,3 +495,85 @@ test('an early fraud warning refunds and cancels every subscription of the email ->and($endedRow->fresh()->stripe_subscription_id)->toBeNull() ->and($server->settings()->first()->is_usable)->toBeFalsy(); }); + +describe('checkout.session.expired', function () { + beforeEach(function () { + Queue::fake([SubscriptionCheckoutReminderJob::class]); + Subscription::create([ + 'team_id' => $this->team->id, + 'stripe_customer_id' => 'cus_TestExpiredCheckout', + 'stripe_invoice_paid' => false, + ]); + }); + + function expiredCheckoutEvent(string $clientReferenceId, ?string $recoveryUrl = 'https://buy.stripe.com/r/test_recoveryabc123'): Event + { + $payload = json_decode(file_get_contents(base_path('tests/Fixtures/stripe/checkout.session.expired.json')), true); + $payload['data']['object']['client_reference_id'] = $clientReferenceId; + if ($recoveryUrl === null) { + $payload['data']['object']['after_expiration'] = null; + } + + return Event::constructFrom($payload); + } + + test('queues one reminder with the recovery url to the user who started checkout', function () { + (new StripeProcessJob(expiredCheckoutEvent("{$this->user->id}:{$this->team->id}")))->handle(); + + Queue::assertPushed(SubscriptionCheckoutReminderJob::class, 1); + Queue::assertPushed(SubscriptionCheckoutReminderJob::class, fn ($job) => $job->user->is($this->user) + && $job->checkoutUrl === 'https://buy.stripe.com/r/test_recoveryabc123'); + expect($this->team->subscription()->first()->stripe_checkout_reminder_sent_at)->not->toBeNull(); + }); + + test('falls back to the subscription page without a recovery url', function () { + (new StripeProcessJob(expiredCheckoutEvent("{$this->user->id}:{$this->team->id}", null)))->handle(); + + Queue::assertPushed(SubscriptionCheckoutReminderJob::class, fn ($job) => $job->checkoutUrl === route('subscription.index')); + }); + + test('does not remind a team that is already subscribed', function () { + $this->team->subscription()->update(['stripe_invoice_paid' => true]); + + (new StripeProcessJob(expiredCheckoutEvent("{$this->user->id}:{$this->team->id}")))->handle(); + + Queue::assertNotPushed(SubscriptionCheckoutReminderJob::class); + }); + + test('sends at most one reminder per team in 7 days', function () { + $event = expiredCheckoutEvent("{$this->user->id}:{$this->team->id}"); + + (new StripeProcessJob($event))->handle(); + $this->travel(6)->days(); + (new StripeProcessJob($event))->handle(); + Queue::assertPushed(SubscriptionCheckoutReminderJob::class, 1); + + $this->travel(2)->days(); + (new StripeProcessJob($event))->handle(); + Queue::assertPushed(SubscriptionCheckoutReminderJob::class, 2); + }); + + test('ignores unknown or unauthorized client reference ids', function (string $clientReferenceId) { + (new StripeProcessJob(expiredCheckoutEvent($clientReferenceId)))->handle(); + + Queue::assertNotPushed(SubscriptionCheckoutReminderJob::class); + })->with([ + 'unknown team' => fn () => "{$this->user->id}:999999", + 'user not in team' => fn () => "999999:{$this->team->id}", + 'member role' => function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + return "{$member->id}:{$this->team->id}"; + }, + ]); + + test('does nothing on self-hosted instances', function () { + config()->set('constants.coolify.self_hosted', true); + + (new StripeProcessJob(expiredCheckoutEvent("{$this->user->id}:{$this->team->id}")))->handle(); + + Queue::assertNotPushed(SubscriptionCheckoutReminderJob::class); + expect($this->team->subscription()->first()->stripe_checkout_reminder_sent_at)->toBeNull(); + }); +}); diff --git a/tests/Fixtures/stripe/checkout.session.expired.json b/tests/Fixtures/stripe/checkout.session.expired.json new file mode 100644 index 0000000000..d80711d408 --- /dev/null +++ b/tests/Fixtures/stripe/checkout.session.expired.json @@ -0,0 +1,109 @@ +{ + "id": "evt_1QxExpiredCheckout0000001", + "object": "event", + "api_version": "2025-02-24.acacia", + "created": 1761779100, + "data": { + "object": { + "id": "cs_test_b1ExpiredSubscriptionCheckout000000000000000000000000001", + "object": "checkout.session", + "adaptive_pricing": null, + "after_expiration": { + "recovery": { + "allow_promotion_codes": false, + "enabled": true, + "expires_at": 1764371100, + "url": "https://buy.stripe.com/r/test_recoveryabc123" + } + }, + "allow_promotion_codes": true, + "amount_subtotal": 1000, + "amount_total": 1000, + "automatic_tax": { + "enabled": true, + "liability": { + "type": "self" + }, + "provider": "stripe", + "status": "requires_location_inputs" + }, + "billing_address_collection": "required", + "cancel_url": "https://app.coolify.io/subscription/new?cancelled=1", + "client_reference_id": "{{client_reference_id}}", + "client_secret": null, + "collected_information": null, + "consent": null, + "consent_collection": null, + "created": 1761777000, + "currency": "usd", + "currency_conversion": null, + "custom_fields": [], + "custom_text": { + "after_submit": null, + "shipping_address": null, + "submit": null, + "terms_of_service_acceptance": null + }, + "customer": "cus_TestExpiredCheckout", + "customer_creation": null, + "customer_details": null, + "customer_email": null, + "discounts": [], + "expires_at": 1761779100, + "invoice": null, + "invoice_creation": null, + "livemode": false, + "locale": null, + "metadata": {}, + "mode": "subscription", + "payment_intent": null, + "payment_link": null, + "payment_method_collection": "if_required", + "payment_method_configuration_details": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "payment_status": "unpaid", + "permissions": null, + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "saved_payment_method_options": { + "allow_redisplay_filters": [ + "always" + ], + "payment_method_remove": null, + "payment_method_save": null + }, + "setup_intent": null, + "shipping_address_collection": null, + "shipping_cost": null, + "shipping_details": null, + "shipping_options": [], + "status": "expired", + "submit_type": null, + "subscription": null, + "success_url": "https://app.coolify.io/?success=1", + "tax_id_collection": { + "enabled": true, + "required": "never" + }, + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "ui_mode": "hosted", + "url": null + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.expired" +}