From f1c1a1c5727cf22ed3f5b5eacaf287a664b720bf Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:23:27 +0100 Subject: [PATCH 01/10] feat: add grace_period_until to users table --- app/Models/User.php | 3 ++- ..._add_grace_period_until_to_users_table.php | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_04_23_092253_add_grace_period_until_to_users_table.php diff --git a/app/Models/User.php b/app/Models/User.php index f42e9e3..5802c16 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,7 +17,7 @@ use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])] +#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements FilamentUser { @@ -35,6 +35,7 @@ class User extends Authenticatable implements FilamentUser 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', + 'grace_period_until' => 'datetime', ]; } diff --git a/database/migrations/2026_04_23_092253_add_grace_period_until_to_users_table.php b/database/migrations/2026_04_23_092253_add_grace_period_until_to_users_table.php new file mode 100644 index 0000000..4050476 --- /dev/null +++ b/database/migrations/2026_04_23_092253_add_grace_period_until_to_users_table.php @@ -0,0 +1,23 @@ +timestamp('grace_period_until')->nullable()->after('password') + ->comment('Set when invoice.payment_failed webhook fires; cleared on payment success or subscription deletion. Drives dashboard past-due banner.'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('grace_period_until'); + }); + } +}; From a39d4b1b943e9d019e7f9732d753731a10549049 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:27:23 +0100 Subject: [PATCH 02/10] feat: consolidate stripe webhook handling into HandleStripeWebhook listener --- app/Listeners/HandleStripeWebhook.php | 36 +++++++++++++++++++ app/Providers/AppServiceProvider.php | 4 +-- .../Payments/HandleStripeWebhookTest.php | 30 ++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 app/Listeners/HandleStripeWebhook.php create mode 100644 tests/Feature/Payments/HandleStripeWebhookTest.php diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php new file mode 100644 index 0000000..401a156 --- /dev/null +++ b/app/Listeners/HandleStripeWebhook.php @@ -0,0 +1,36 @@ +payload['type'] ?? null; + $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + + if ($stripeCustomerId === null) { + return; + } + + $user = User::where('stripe_id', $stripeCustomerId)->first(); + + if ($user === null) { + return; + } + + match ($type) { + 'customer.subscription.created' => $this->bustPlanCache($user), + default => null, + }; + } + + private function bustPlanCache(User $user): void + { + Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 792bda2..e805751 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ namespace App\Providers; -use App\Listeners\DowngradeUserOnSubscriptionDeleted; +use App\Listeners\HandleStripeWebhook; use App\Services\ApiLogger; use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Services\LlmPrediction\GeminiPredictionProvider; @@ -41,7 +41,7 @@ class AppServiceProvider extends ServiceProvider { $this->configureDefaults(); - Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class); + Event::listen(WebhookReceived::class, HandleStripeWebhook::class); } /** diff --git a/tests/Feature/Payments/HandleStripeWebhookTest.php b/tests/Feature/Payments/HandleStripeWebhookTest.php new file mode 100644 index 0000000..4a50c04 --- /dev/null +++ b/tests/Feature/Payments/HandleStripeWebhookTest.php @@ -0,0 +1,30 @@ +create(['stripe_id' => 'cus_created_1']); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['customer' => 'cus_created_1']], + ])); + + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); + +it('ignores subscription.created when the user is not found', function (): void { + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['customer' => 'cus_unknown']], + ])); + + expect(true)->toBeTrue(); +}); From 25b79f095beb12a96ffe66434a1a4dfcf064d96f Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:31:07 +0100 Subject: [PATCH 03/10] feat: bust plan cache on customer.subscription.updated --- app/Listeners/HandleStripeWebhook.php | 3 ++- tests/Feature/Payments/HandleStripeWebhookTest.php | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index 401a156..d206929 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -24,7 +24,8 @@ final class HandleStripeWebhook } match ($type) { - 'customer.subscription.created' => $this->bustPlanCache($user), + 'customer.subscription.created', + 'customer.subscription.updated' => $this->bustPlanCache($user), default => null, }; } diff --git a/tests/Feature/Payments/HandleStripeWebhookTest.php b/tests/Feature/Payments/HandleStripeWebhookTest.php index 4a50c04..3f441d6 100644 --- a/tests/Feature/Payments/HandleStripeWebhookTest.php +++ b/tests/Feature/Payments/HandleStripeWebhookTest.php @@ -28,3 +28,15 @@ it('ignores subscription.created when the user is not found', function (): void expect(true)->toBeTrue(); }); + +it('busts the plan cache on customer.subscription.updated', function (): void { + $user = User::factory()->create(['stripe_id' => 'cus_updated_1']); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => ['customer' => 'cus_updated_1']], + ])); + + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); From b9d457578ca8d7b9c21038d34dcb6676c28e920d Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:34:05 +0100 Subject: [PATCH 04/10] feat: fold subscription deletion handling into HandleStripeWebhook --- .../DowngradeUserOnSubscriptionDeleted.php | 37 ---------------- app/Listeners/HandleStripeWebhook.php | 14 +++++++ ...DowngradeUserOnSubscriptionDeletedTest.php | 42 ------------------- .../Payments/HandleStripeWebhookTest.php | 27 ++++++++++++ 4 files changed, 41 insertions(+), 79 deletions(-) delete mode 100644 app/Listeners/DowngradeUserOnSubscriptionDeleted.php delete mode 100644 tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php diff --git a/app/Listeners/DowngradeUserOnSubscriptionDeleted.php b/app/Listeners/DowngradeUserOnSubscriptionDeleted.php deleted file mode 100644 index eaee68c..0000000 --- a/app/Listeners/DowngradeUserOnSubscriptionDeleted.php +++ /dev/null @@ -1,37 +0,0 @@ -payload['type'] ?? null) !== 'customer.subscription.deleted') { - return; - } - - $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; - - if (! $stripeCustomerId) { - return; - } - - $user = User::where('stripe_id', $stripeCustomerId)->first(); - - if (! $user) { - return; - } - - UserNotificationPreference::query() - ->where('user_id', $user->id) - ->whereIn('channel', ['whatsapp', 'sms']) - ->update(['enabled' => false]); - - Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); - } -} diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index d206929..85b86ac 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -3,6 +3,7 @@ namespace App\Listeners; use App\Models\User; +use App\Models\UserNotificationPreference; use Illuminate\Support\Facades\Cache; use Laravel\Cashier\Events\WebhookReceived; @@ -26,10 +27,23 @@ final class HandleStripeWebhook match ($type) { 'customer.subscription.created', 'customer.subscription.updated' => $this->bustPlanCache($user), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), default => null, }; } + private function handleSubscriptionDeleted(User $user): void + { + UserNotificationPreference::query() + ->where('user_id', $user->id) + ->whereIn('channel', ['whatsapp', 'sms']) + ->update(['enabled' => false]); + + $user->forceFill(['grace_period_until' => null])->save(); + + $this->bustPlanCache($user); + } + private function bustPlanCache(User $user): void { Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); diff --git a/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php b/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php deleted file mode 100644 index 0aedc7a..0000000 --- a/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php +++ /dev/null @@ -1,42 +0,0 @@ -create(['stripe_id' => 'cus_test_123']); - - UserNotificationPreference::query()->insert([ - ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], - ['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], - ['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], - ]); - - (new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([ - 'type' => 'customer.subscription.deleted', - 'data' => ['object' => ['customer' => 'cus_test_123']], - ])); - - expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse(); - expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse(); - expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue(); -}); - -it('ignores non-deletion webhook events', function (): void { - $user = User::factory()->create(['stripe_id' => 'cus_test_456']); - UserNotificationPreference::query()->insert([ - ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], - ]); - - (new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([ - 'type' => 'customer.subscription.updated', - 'data' => ['object' => ['customer' => 'cus_test_456']], - ])); - - expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeTrue(); -}); diff --git a/tests/Feature/Payments/HandleStripeWebhookTest.php b/tests/Feature/Payments/HandleStripeWebhookTest.php index 3f441d6..e491689 100644 --- a/tests/Feature/Payments/HandleStripeWebhookTest.php +++ b/tests/Feature/Payments/HandleStripeWebhookTest.php @@ -2,6 +2,7 @@ use App\Listeners\HandleStripeWebhook; use App\Models\User; +use App\Models\UserNotificationPreference; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Laravel\Cashier\Events\WebhookReceived; @@ -40,3 +41,29 @@ it('busts the plan cache on customer.subscription.updated', function (): void { expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); }); + +it('on customer.subscription.deleted disables whatsapp+sms prefs, clears grace, busts cache', function (): void { + $user = User::factory()->create([ + 'stripe_id' => 'cus_deleted_1', + 'grace_period_until' => now()->addDays(5), + ]); + + UserNotificationPreference::query()->insert([ + ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ]); + + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_deleted_1']], + ])); + + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue(); + expect($user->fresh()->grace_period_until)->toBeNull(); + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); From 2078c4b83e3d777c5ae34e6982d9a3810954c083 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:37:50 +0100 Subject: [PATCH 05/10] feat: clear grace period on invoice.payment_succeeded Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Listeners/HandleStripeWebhook.php | 7 +++++ .../Payments/HandleStripeWebhookTest.php | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/Listeners/HandleStripeWebhook.php b/app/Listeners/HandleStripeWebhook.php index 85b86ac..305775b 100644 --- a/app/Listeners/HandleStripeWebhook.php +++ b/app/Listeners/HandleStripeWebhook.php @@ -28,6 +28,7 @@ final class HandleStripeWebhook 'customer.subscription.created', 'customer.subscription.updated' => $this->bustPlanCache($user), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), + 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), default => null, }; } @@ -44,6 +45,12 @@ final class HandleStripeWebhook $this->bustPlanCache($user); } + private function handlePaymentSucceeded(User $user): void + { + $user->forceFill(['grace_period_until' => null])->save(); + $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 e491689..d3edee6 100644 --- a/tests/Feature/Payments/HandleStripeWebhookTest.php +++ b/tests/Feature/Payments/HandleStripeWebhookTest.php @@ -67,3 +67,30 @@ it('on customer.subscription.deleted disables whatsapp+sms prefs, clears grace, expect($user->fresh()->grace_period_until)->toBeNull(); expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); }); + +it('on invoice.payment_succeeded clears grace_period_until and busts cache', function (): void { + $user = User::factory()->create([ + 'stripe_id' => 'cus_paid_1', + 'grace_period_until' => now()->addDays(4), + ]); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['customer' => 'cus_paid_1']], + ])); + + expect($user->fresh()->grace_period_until)->toBeNull(); + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); + +it('invoice.payment_succeeded is a no-op when grace was never set', function (): void { + $user = User::factory()->create(['stripe_id' => 'cus_paid_2', 'grace_period_until' => null]); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['customer' => 'cus_paid_2']], + ])); + + expect($user->fresh()->grace_period_until)->toBeNull(); +}); From de2499636f0b305776ce7d1a9e6bd9e970fd5318 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:44:37 +0100 Subject: [PATCH 06/10] feat: add day-3 branded payment-failure reminder mailable --- app/Mail/PaymentFailedDay3Reminder.php | 33 ++ .../emails/payment-failed-day-3.blade.php | 22 ++ .../views/vendor/mail/html/button.blade.php | 24 ++ .../views/vendor/mail/html/footer.blade.php | 11 + .../views/vendor/mail/html/header.blade.php | 12 + .../views/vendor/mail/html/layout.blade.php | 58 ++++ .../views/vendor/mail/html/message.blade.php | 27 ++ .../views/vendor/mail/html/panel.blade.php | 14 + .../views/vendor/mail/html/subcopy.blade.php | 7 + .../views/vendor/mail/html/table.blade.php | 3 + .../views/vendor/mail/html/themes/default.css | 297 ++++++++++++++++++ .../views/vendor/mail/text/button.blade.php | 1 + .../views/vendor/mail/text/footer.blade.php | 1 + .../views/vendor/mail/text/header.blade.php | 1 + .../views/vendor/mail/text/layout.blade.php | 9 + .../views/vendor/mail/text/message.blade.php | 27 ++ .../views/vendor/mail/text/panel.blade.php | 1 + .../views/vendor/mail/text/subcopy.blade.php | 1 + .../views/vendor/mail/text/table.blade.php | 1 + 19 files changed, 550 insertions(+) create mode 100644 app/Mail/PaymentFailedDay3Reminder.php create mode 100644 resources/views/emails/payment-failed-day-3.blade.php create mode 100644 resources/views/vendor/mail/html/button.blade.php create mode 100644 resources/views/vendor/mail/html/footer.blade.php create mode 100644 resources/views/vendor/mail/html/header.blade.php create mode 100644 resources/views/vendor/mail/html/layout.blade.php create mode 100644 resources/views/vendor/mail/html/message.blade.php create mode 100644 resources/views/vendor/mail/html/panel.blade.php create mode 100644 resources/views/vendor/mail/html/subcopy.blade.php create mode 100644 resources/views/vendor/mail/html/table.blade.php create mode 100644 resources/views/vendor/mail/html/themes/default.css create mode 100644 resources/views/vendor/mail/text/button.blade.php create mode 100644 resources/views/vendor/mail/text/footer.blade.php create mode 100644 resources/views/vendor/mail/text/header.blade.php create mode 100644 resources/views/vendor/mail/text/layout.blade.php create mode 100644 resources/views/vendor/mail/text/message.blade.php create mode 100644 resources/views/vendor/mail/text/panel.blade.php create mode 100644 resources/views/vendor/mail/text/subcopy.blade.php create mode 100644 resources/views/vendor/mail/text/table.blade.php diff --git a/app/Mail/PaymentFailedDay3Reminder.php b/app/Mail/PaymentFailedDay3Reminder.php new file mode 100644 index 0000000..04a5216 --- /dev/null +++ b/app/Mail/PaymentFailedDay3Reminder.php @@ -0,0 +1,33 @@ + $this->user->name, + 'tier' => PlanFeatures::for($this->user)->tier(), + 'portalUrl' => route('billing.portal'), + ], + ); + } +} diff --git a/resources/views/emails/payment-failed-day-3.blade.php b/resources/views/emails/payment-failed-day-3.blade.php new file mode 100644 index 0000000..82ee4ec --- /dev/null +++ b/resources/views/emails/payment-failed-day-3.blade.php @@ -0,0 +1,22 @@ + +# Payment retry in progress + +Hi {{ $name }}, + +We tried to renew your FuelAlert **{{ ucfirst($tier) }}** subscription but the +payment didn't go through. Stripe will retry automatically over the next couple +of days. + +If the card on file is out of date, please update it now so you don't lose your +{{ ucfirst($tier) }} features: + + + Update payment method + + +If Stripe's retries succeed, you won't need to do anything — you'll just get a +normal payment receipt. + +Thanks for using FuelAlert, +The FuelAlert Team + diff --git a/resources/views/vendor/mail/html/button.blade.php b/resources/views/vendor/mail/html/button.blade.php new file mode 100644 index 0000000..050e969 --- /dev/null +++ b/resources/views/vendor/mail/html/button.blade.php @@ -0,0 +1,24 @@ +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) + + + + + diff --git a/resources/views/vendor/mail/html/footer.blade.php b/resources/views/vendor/mail/html/footer.blade.php new file mode 100644 index 0000000..3ff41f8 --- /dev/null +++ b/resources/views/vendor/mail/html/footer.blade.php @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/resources/views/vendor/mail/html/header.blade.php b/resources/views/vendor/mail/html/header.blade.php new file mode 100644 index 0000000..479cf67 --- /dev/null +++ b/resources/views/vendor/mail/html/header.blade.php @@ -0,0 +1,12 @@ +@props(['url']) + + + +@if (trim($slot) === 'Laravel') + +@else +{!! $slot !!} +@endif + + + diff --git a/resources/views/vendor/mail/html/layout.blade.php b/resources/views/vendor/mail/html/layout.blade.php new file mode 100644 index 0000000..037efe3 --- /dev/null +++ b/resources/views/vendor/mail/html/layout.blade.php @@ -0,0 +1,58 @@ + + + +{{ config('app.name') }} + + + + + +{!! $head ?? '' !!} + + + + + + + + + + diff --git a/resources/views/vendor/mail/html/message.blade.php b/resources/views/vendor/mail/html/message.blade.php new file mode 100644 index 0000000..a16bace --- /dev/null +++ b/resources/views/vendor/mail/html/message.blade.php @@ -0,0 +1,27 @@ + +{{-- Header --}} + + +{{ config('app.name') }} + + + +{{-- Body --}} +{!! $slot !!} + +{{-- Subcopy --}} +@isset($subcopy) + + +{!! $subcopy !!} + + +@endisset + +{{-- Footer --}} + + +© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} + + + diff --git a/resources/views/vendor/mail/html/panel.blade.php b/resources/views/vendor/mail/html/panel.blade.php new file mode 100644 index 0000000..2975a60 --- /dev/null +++ b/resources/views/vendor/mail/html/panel.blade.php @@ -0,0 +1,14 @@ + + + + + + diff --git a/resources/views/vendor/mail/html/subcopy.blade.php b/resources/views/vendor/mail/html/subcopy.blade.php new file mode 100644 index 0000000..790ce6c --- /dev/null +++ b/resources/views/vendor/mail/html/subcopy.blade.php @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/views/vendor/mail/html/table.blade.php b/resources/views/vendor/mail/html/table.blade.php new file mode 100644 index 0000000..a5f3348 --- /dev/null +++ b/resources/views/vendor/mail/html/table.blade.php @@ -0,0 +1,3 @@ +
+{{ Illuminate\Mail\Markdown::parse($slot) }} +
diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css new file mode 100644 index 0000000..d6ee6f0 --- /dev/null +++ b/resources/views/vendor/mail/html/themes/default.css @@ -0,0 +1,297 @@ +/* Base */ + +body, +body *:not(html):not(style):not(br):not(tr):not(code) { + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + position: relative; +} + +body { + -webkit-text-size-adjust: none; + background-color: #ffffff; + color: #52525b; + height: 100%; + line-height: 1.4; + margin: 0; + padding: 0; + width: 100% !important; +} + +p, +ul, +ol, +blockquote { + line-height: 1.4; + text-align: start; +} + +a { + color: #18181b; +} + +a img { + border: none; +} + +/* Typography */ + +h1 { + color: #18181b; + font-size: 18px; + font-weight: bold; + margin-top: 0; + text-align: start; +} + +h2 { + font-size: 16px; + font-weight: bold; + margin-top: 0; + text-align: start; +} + +h3 { + font-size: 14px; + font-weight: bold; + margin-top: 0; + text-align: left; +} + +p { + font-size: 16px; + line-height: 1.5em; + margin-top: 0; + text-align: left; +} + +p.sub { + font-size: 12px; +} + +img { + max-width: 100%; +} + +/* Layout */ + +.wrapper { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #fafafa; + margin: 0; + padding: 0; + width: 100%; +} + +.content { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +/* Header */ + +.header { + padding: 25px 0; + text-align: center; +} + +.header a { + color: #18181b; + font-size: 19px; + font-weight: bold; + text-decoration: none; +} + +/* Logo */ + +.logo { + height: 75px; + margin-top: 15px; + margin-bottom: 10px; + max-height: 75px; + width: 75px; +} + +/* Body */ + +.body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + background-color: #fafafa; + border-bottom: 1px solid #fafafa; + border-top: 1px solid #fafafa; + margin: 0; + padding: 0; + width: 100%; +} + +.inner-body { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; + background-color: #ffffff; + border-color: #e4e4e7; + border-radius: 4px; + border-width: 1px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); + margin: 0 auto; + padding: 0; + width: 570px; +} + +.inner-body a { + word-break: break-all; +} + +/* Subcopy */ + +.subcopy { + border-top: 1px solid #e4e4e7; + margin-top: 25px; + padding-top: 25px; +} + +.subcopy p { + font-size: 14px; +} + +/* Footer */ + +.footer { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 570px; + margin: 0 auto; + padding: 0; + text-align: center; + width: 570px; +} + +.footer p { + color: #a1a1aa; + font-size: 12px; + text-align: center; +} + +.footer a { + color: #a1a1aa; + text-decoration: underline; +} + +/* Tables */ + +.table table { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 30px auto; + width: 100%; +} + +.table th { + border-bottom: 1px solid #e4e4e7; + margin: 0; + padding-bottom: 8px; +} + +.table td { + color: #52525b; + font-size: 15px; + line-height: 18px; + margin: 0; + padding: 10px 0; +} + +.content-cell { + max-width: 100vw; + padding: 32px; +} + +/* Buttons */ + +.action { + -premailer-cellpadding: 0; + -premailer-cellspacing: 0; + -premailer-width: 100%; + margin: 30px auto; + padding: 0; + text-align: center; + width: 100%; + float: unset; +} + +.button { + -webkit-text-size-adjust: none; + border-radius: 4px; + color: #fff; + display: inline-block; + overflow: hidden; + text-decoration: none; +} + +.button-blue, +.button-primary { + background-color: #18181b; + border-bottom: 8px solid #18181b; + border-left: 18px solid #18181b; + border-right: 18px solid #18181b; + border-top: 8px solid #18181b; +} + +.button-green, +.button-success { + background-color: #16a34a; + border-bottom: 8px solid #16a34a; + border-left: 18px solid #16a34a; + border-right: 18px solid #16a34a; + border-top: 8px solid #16a34a; +} + +.button-red, +.button-error { + background-color: #dc2626; + border-bottom: 8px solid #dc2626; + border-left: 18px solid #dc2626; + border-right: 18px solid #dc2626; + border-top: 8px solid #dc2626; +} + +/* Panels */ + +.panel { + border-left: #18181b solid 4px; + margin: 21px 0; +} + +.panel-content { + background-color: #fafafa; + color: #52525b; + padding: 16px; +} + +.panel-content p { + color: #52525b; +} + +.panel-item { + padding: 0; +} + +.panel-item p:last-of-type { + margin-bottom: 0; + padding-bottom: 0; +} + +/* Utilities */ + +.break-all { + word-break: break-all; +} diff --git a/resources/views/vendor/mail/text/button.blade.php b/resources/views/vendor/mail/text/button.blade.php new file mode 100644 index 0000000..97444eb --- /dev/null +++ b/resources/views/vendor/mail/text/button.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/footer.blade.php b/resources/views/vendor/mail/text/footer.blade.php new file mode 100644 index 0000000..3338f62 --- /dev/null +++ b/resources/views/vendor/mail/text/footer.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/header.blade.php b/resources/views/vendor/mail/text/header.blade.php new file mode 100644 index 0000000..97444eb --- /dev/null +++ b/resources/views/vendor/mail/text/header.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/layout.blade.php b/resources/views/vendor/mail/text/layout.blade.php new file mode 100644 index 0000000..ec58e83 --- /dev/null +++ b/resources/views/vendor/mail/text/layout.blade.php @@ -0,0 +1,9 @@ +{!! strip_tags($header ?? '') !!} + +{!! strip_tags($slot) !!} +@isset($subcopy) + +{!! strip_tags($subcopy) !!} +@endisset + +{!! strip_tags($footer ?? '') !!} diff --git a/resources/views/vendor/mail/text/message.blade.php b/resources/views/vendor/mail/text/message.blade.php new file mode 100644 index 0000000..80bce21 --- /dev/null +++ b/resources/views/vendor/mail/text/message.blade.php @@ -0,0 +1,27 @@ + + {{-- Header --}} + + + {{ config('app.name') }} + + + + {{-- Body --}} + {{ $slot }} + + {{-- Subcopy --}} + @isset($subcopy) + + + {{ $subcopy }} + + + @endisset + + {{-- Footer --}} + + + © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + + + diff --git a/resources/views/vendor/mail/text/panel.blade.php b/resources/views/vendor/mail/text/panel.blade.php new file mode 100644 index 0000000..3338f62 --- /dev/null +++ b/resources/views/vendor/mail/text/panel.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/subcopy.blade.php b/resources/views/vendor/mail/text/subcopy.blade.php new file mode 100644 index 0000000..3338f62 --- /dev/null +++ b/resources/views/vendor/mail/text/subcopy.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/table.blade.php b/resources/views/vendor/mail/text/table.blade.php new file mode 100644 index 0000000..3338f62 --- /dev/null +++ b/resources/views/vendor/mail/text/table.blade.php @@ -0,0 +1 @@ +{{ $slot }} From c127cc379e8ee0449ea6db3668ba72077f539619 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:48:22 +0100 Subject: [PATCH 07/10] feat: add day-5 branded payment-failure reminder mailable --- app/Mail/PaymentFailedDay5Reminder.php | 35 +++++++++++++++++++ .../emails/payment-failed-day-5.blade.php | 27 ++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/Mail/PaymentFailedDay5Reminder.php create mode 100644 resources/views/emails/payment-failed-day-5.blade.php diff --git a/app/Mail/PaymentFailedDay5Reminder.php b/app/Mail/PaymentFailedDay5Reminder.php new file mode 100644 index 0000000..1aca82a --- /dev/null +++ b/app/Mail/PaymentFailedDay5Reminder.php @@ -0,0 +1,35 @@ +user)->tier(); + + return new Envelope( + subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow', + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.payment-failed-day-5', + with: [ + 'name' => $this->user->name, + 'tier' => PlanFeatures::for($this->user)->tier(), + 'portalUrl' => route('billing.portal'), + ], + ); + } +} diff --git a/resources/views/emails/payment-failed-day-5.blade.php b/resources/views/emails/payment-failed-day-5.blade.php new file mode 100644 index 0000000..12a34d8 --- /dev/null +++ b/resources/views/emails/payment-failed-day-5.blade.php @@ -0,0 +1,27 @@ + +# Your {{ ucfirst($tier) }} features end tomorrow + +Hi {{ $name }}, + +Stripe has been trying to renew your subscription for the past few days without +success. If the next retry doesn't go through, your **{{ ucfirst($tier) }}** +subscription will be cancelled tomorrow and your account will move back to the +Free tier. + +Here's what you'll lose on Free: + +- Smart fill-up / wait recommendations +- Multi-channel alerts (WhatsApp, SMS, push) +- Advanced fuel-price predictions + +You can keep everything running by updating your card now: + + + Update payment method + + +If you meant to cancel, no action needed — you'll just drop to Free automatically. + +Thanks for using FuelAlert, +The FuelAlert Team + From 5b17f4cae4960a46143bf6efb590137a63fc7ebf Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:51:13 +0100 Subject: [PATCH 08/10] feat: add SendPaymentFailedReminderJob with grace guard --- app/Jobs/SendPaymentFailedReminderJob.php | 44 +++++++++++++++ .../SendPaymentFailedReminderJobTest.php | 54 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 app/Jobs/SendPaymentFailedReminderJob.php create mode 100644 tests/Feature/Payments/SendPaymentFailedReminderJobTest.php diff --git a/app/Jobs/SendPaymentFailedReminderJob.php b/app/Jobs/SendPaymentFailedReminderJob.php new file mode 100644 index 0000000..28cee69 --- /dev/null +++ b/app/Jobs/SendPaymentFailedReminderJob.php @@ -0,0 +1,44 @@ +onQueue('notifications'); + } + + public function handle(): void + { + $user = User::find($this->userId); + + if ($user === null) { + return; + } + + if ($user->grace_period_until === null) { + return; + } + + $mailable = match ($this->day) { + 3 => new PaymentFailedDay3Reminder($user), + 5 => new PaymentFailedDay5Reminder($user), + default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"), + }; + + Mail::to($user->email)->send($mailable); + } +} diff --git a/tests/Feature/Payments/SendPaymentFailedReminderJobTest.php b/tests/Feature/Payments/SendPaymentFailedReminderJobTest.php new file mode 100644 index 0000000..35dc787 --- /dev/null +++ b/tests/Feature/Payments/SendPaymentFailedReminderJobTest.php @@ -0,0 +1,54 @@ +create(['grace_period_until' => now()->addDays(2)]); + + (new SendPaymentFailedReminderJob($user->id, 3))->handle(); + + Mail::assertSent(PaymentFailedDay3Reminder::class, fn ($mail) => $mail->hasTo($user->email)); +}); + +it('sends the day-5 mailable when grace_period_until is still set', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => now()->addDay()]); + + (new SendPaymentFailedReminderJob($user->id, 5))->handle(); + + Mail::assertSent(PaymentFailedDay5Reminder::class, fn ($mail) => $mail->hasTo($user->email)); +}); + +it('silently skips the day-3 mailable when grace has been cleared', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => null]); + + (new SendPaymentFailedReminderJob($user->id, 3))->handle(); + + Mail::assertNothingSent(); +}); + +it('silently skips the day-5 mailable when grace has been cleared', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => null]); + + (new SendPaymentFailedReminderJob($user->id, 5))->handle(); + + Mail::assertNothingSent(); +}); + +it('silently skips when the user no longer exists', function (): void { + Mail::fake(); + + (new SendPaymentFailedReminderJob(999999, 3))->handle(); + + Mail::assertNothingSent(); +}); From b7175169f0f463d1060e67e1132925becd26edc9 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:56:26 +0100 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20handle=20invoice.payment=5Ffailed?= =?UTF-8?q?=20=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(); +}); From 36444cde05efd7b19a8da310d9a25c8f32544103 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:59:51 +0100 Subject: [PATCH 10/10] feat: add past-due payment banner to dashboard Show an amber banner to logged-in users whose grace_period_until is set, linking to the Stripe Customer Portal to update their card. Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/views/dashboard.blade.php | 1 + .../views/partials/past-due-banner.blade.php | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 resources/views/partials/past-due-banner.blade.php diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 8f08c05..2bf8087 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1,5 +1,6 @@
+ @include('partials.past-due-banner')
diff --git a/resources/views/partials/past-due-banner.blade.php b/resources/views/partials/past-due-banner.blade.php new file mode 100644 index 0000000..f3b68fe --- /dev/null +++ b/resources/views/partials/past-due-banner.blade.php @@ -0,0 +1,18 @@ +@auth + @if (auth()->user()->grace_period_until !== null) +
+
+ We couldn't charge your card. + Update your payment method by + {{ auth()->user()->grace_period_until->format('l, j M') }} + or your paid features will end. +
+ + Update card + +
+ @endif +@endauth