feat(subscription): email checkout reminders for expired sessions

Enable Stripe checkout recovery links and send rate-limited reminders to the
admin who started an unfinished subscription checkout.
This commit is contained in:
Andras Bacsai committed 2026-10-29 23:35:34 +01:00
1 parent 0d398ced24
commit 72c8209285
9 files changed
+295 -1

No files matched your search

@@ -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]),
]);
+32
View File
@@ -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);
@@ -0,0 +1,32 @@
<?php
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SubscriptionCheckoutReminderJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public string $checkoutUrl)
{
$this->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);
}
}
+1
View File
@@ -28,6 +28,7 @@ class Subscription extends Model
return [
'stripe_refunded_at' => 'datetime',
'ended_at' => 'datetime',
'stripe_checkout_reminder_sent_at' => 'datetime',
];
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->timestamp('stripe_checkout_reminder_sent_at')->nullable();
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn('stripe_checkout_reminder_sent_at');
});
}
};
@@ -0,0 +1,7 @@
<x-emails.layout>
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.
</x-emails.layout>
@@ -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';
})
@@ -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();
});
});
@@ -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"
}