From b7175169f0f463d1060e67e1132925becd26edc9 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:56:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20handle=20invoice.payment=5Ffailed=20?= =?UTF-8?q?=E2=80=94=20set=20grace=20period=20and=20queue=20reminders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Listeners/HandleStripeWebhook.php | 20 +++++++++ .../Payments/HandleStripeWebhookTest.php | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index 305775b..23cba12 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -2,8 +2,10 @@ namespace App\Listeners; +use App\Jobs\SendPaymentFailedReminderJob; use App\Models\User; use App\Models\UserNotificationPreference; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Laravel\Cashier\Events\WebhookReceived; @@ -29,6 +31,7 @@ final class HandleStripeWebhook 'customer.subscription.updated' => $this->bustPlanCache($user), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), + 'invoice.payment_failed' => $this->handlePaymentFailed($user), default => null, }; } @@ -51,6 +54,23 @@ final class HandleStripeWebhook $this->bustPlanCache($user); } + private function handlePaymentFailed(User $user): void + { + // Idempotency: only the first failed-payment event in a grace window + // transitions state + dispatches reminders. Stripe may fire this event + // multiple times per billing cycle (once per failed retry attempt). + if ($user->grace_period_until !== null) { + return; + } + + $user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save(); + + SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3)); + SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5)); + + $this->bustPlanCache($user); + } + private function bustPlanCache(User $user): void { Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); diff --git a/tests/Feature/Payments/HandleStripeWebhookTest.php b/tests/Feature/Payments/HandleStripeWebhookTest.php index d3edee6..86e8a8d 100644 --- a/tests/Feature/Payments/HandleStripeWebhookTest.php +++ b/tests/Feature/Payments/HandleStripeWebhookTest.php @@ -1,10 +1,12 @@ fresh()->grace_period_until)->toBeNull(); }); + +it('on invoice.payment_failed sets grace_period_until 5 days out and queues both reminders', function (): void { + Queue::fake(); + $user = User::factory()->create(['stripe_id' => 'cus_failed_1', 'grace_period_until' => null]); + + $before = now(); + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_failed', + 'data' => ['object' => ['customer' => 'cus_failed_1']], + ])); + + $grace = $user->fresh()->grace_period_until; + expect($grace)->not->toBeNull(); + expect($grace->greaterThanOrEqualTo($before->copy()->addDays(5)->subSecond()))->toBeTrue(); + expect($grace->lessThanOrEqualTo($before->copy()->addDays(5)->addSeconds(5)))->toBeTrue(); + + Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) { + return $job->userId === $user->id && $job->day === 3; + }); + Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) { + return $job->userId === $user->id && $job->day === 5; + }); + Queue::assertPushed(SendPaymentFailedReminderJob::class, 2); +}); + +it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void { + Queue::fake(); + $existingGrace = now()->addDays(3)->startOfSecond(); + $user = User::factory()->create(['stripe_id' => 'cus_failed_2', 'grace_period_until' => $existingGrace]); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_failed', + 'data' => ['object' => ['customer' => 'cus_failed_2']], + ])); + + // grace_period_until unchanged (same value) + expect($user->fresh()->grace_period_until->equalTo($existingGrace))->toBeTrue(); + + // No new jobs queued + Queue::assertNothingPushed(); +});