mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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:
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]),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class Subscription extends Model
|
||||
return [
|
||||
'stripe_refunded_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'stripe_checkout_reminder_sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user