# Stripe Subscription Lifecycle Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement the full Stripe subscription lifecycle — consolidated webhook handling, 5-day grace period, branded dunning emails, and past-due dashboard banner — on top of the existing Cashier + `Plan` + `PlanFeatures` foundation. **Architecture:** A single consolidated `HandleStripeWebhook` listener replaces the existing per-event `DowngradeUserOnSubscriptionDeleted` listener and routes on `$event->payload['type']` to keep plan cache and grace state in sync with Stripe. Payment-failure dunning is split: Stripe sends the transactional "update your card" email; we queue two branded reminders (day 3, day 5) via a `SendPaymentFailedReminderJob` that self-cancels when `grace_period_until` is cleared. **Tech Stack:** Laravel 13, Cashier, Laravel queues (Redis), Pest, Livewire, Blade, Tailwind. **Related spec:** `docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md` --- ## File Structure **New files:** - `database/migrations/_add_grace_period_until_to_users_table.php` - `app/Listeners/HandleStripeWebhook.php` - `app/Mail/PaymentFailedDay3Reminder.php` - `app/Mail/PaymentFailedDay5Reminder.php` - `app/Jobs/SendPaymentFailedReminderJob.php` - `resources/views/emails/payment-failed-day-3.blade.php` - `resources/views/emails/payment-failed-day-5.blade.php` - `resources/views/partials/past-due-banner.blade.php` - `tests/Feature/Payments/HandleStripeWebhookTest.php` - `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php` **Modified files:** - `app/Models/User.php` — add `grace_period_until` to `#[Fillable]` and casts - `app/Providers/AppServiceProvider.php:44` — swap listener binding - `database/factories/UserFactory.php` — add fillable key if factory explicitly sets it (likely no-op) - `resources/views/dashboard.blade.php` — include past-due banner partial **Deleted files (Task 4):** - `app/Listeners/DowngradeUserOnSubscriptionDeleted.php` - `tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php` **Conventions to follow:** - Tests use Pest (`it(...)`), `RefreshDatabase` trait, `User::factory()` - Each individual test file must run with a 10-second timeout - PHP 8.4: constructor property promotion, `final` for listeners/jobs, `readonly` properties where applicable - Run `vendor/bin/pint --dirty --format agent` before every commit - Commit message style: existing repo uses `type: description` (feat/fix/docs/refactor/test) --- ### Task 1: Add `grace_period_until` column + model integration **Files:** - Create: `database/migrations/_add_grace_period_until_to_users_table.php` - Modify: `app/Models/User.php:20` - Test: use existing factory; no dedicated test file - [ ] **Step 1: Create the migration** Run: `php artisan make:migration add_grace_period_until_to_users_table --table=users --no-interaction` - [ ] **Step 2: Fill in the migration body** ```php 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'); }); } }; ``` - [ ] **Step 3: Update the User model fillable + casts** Edit `app/Models/User.php:20` — add `grace_period_until` to the `#[Fillable]` attribute: ```php #[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])] ``` Edit the `casts()` method to add the datetime cast: ```php protected function casts(): array { return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'grace_period_until' => 'datetime', ]; } ``` - [ ] **Step 4: Run the migration** Run: `php artisan migrate --no-interaction` Expected: migration runs cleanly; `users` table now has `grace_period_until` nullable timestamp. - [ ] **Step 5: Smoke-test the column is readable/writable** Run: `php artisan tinker --execute 'App\Models\User::factory()->create(["grace_period_until" => now()->addDays(5)])->grace_period_until->format("Y-m-d");'` Expected: prints a date 5 days from now. - [ ] **Step 6: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add database/migrations app/Models/User.php git commit -m "feat: add grace_period_until to users table" ``` --- ### Task 2: Create `HandleStripeWebhook` listener with `subscription.created` branch **Files:** - Create: `app/Listeners/HandleStripeWebhook.php` - Modify: `app/Providers/AppServiceProvider.php:44` - Test: `tests/Feature/Payments/HandleStripeWebhookTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Payments/HandleStripeWebhookTest.php`: ```php 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(); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: FAIL — "Class App\\Listeners\\HandleStripeWebhook not found" - [ ] **Step 3: Create the listener with the subscription.created branch** Create `app/Listeners/HandleStripeWebhook.php`: ```php 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}"); } } ``` - [ ] **Step 4: Wire it in AppServiceProvider (temporarily alongside the old listener)** Edit `app/Providers/AppServiceProvider.php`: Replace the `use App\Listeners\DowngradeUserOnSubscriptionDeleted;` line with: ```php use App\Listeners\HandleStripeWebhook; ``` Replace line 44 (`Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);`) with: ```php Event::listen(WebhookReceived::class, HandleStripeWebhook::class); ``` > Note: the old `DowngradeUserOnSubscriptionDeleted` listener class and its test remain in place for now — they will be deleted in Task 4 once the new listener fully absorbs their behaviour. - [ ] **Step 5: Run test to verify it passes** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 2 passed - [ ] **Step 6: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Listeners/HandleStripeWebhook.php app/Providers/AppServiceProvider.php tests/Feature/Payments/HandleStripeWebhookTest.php git commit -m "feat: consolidate stripe webhook handling into HandleStripeWebhook listener" ``` --- ### Task 3: Add `customer.subscription.updated` branch **Files:** - Modify: `app/Listeners/HandleStripeWebhook.php` - Test: `tests/Feature/Payments/HandleStripeWebhookTest.php` - [ ] **Step 1: Append the failing test** Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: ```php 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(); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 3 tests, 1 failure (the new one — cache still has 'stale'). - [ ] **Step 3: Add the branch** In `app/Listeners/HandleStripeWebhook.php`, edit the `match` expression: ```php match ($type) { 'customer.subscription.created', 'customer.subscription.updated' => $this->bustPlanCache($user), default => null, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 3 passed - [ ] **Step 5: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php git commit -m "feat: bust plan cache on customer.subscription.updated" ``` --- ### Task 4: Absorb `customer.subscription.deleted` behaviour + delete old listener **Files:** - Modify: `app/Listeners/HandleStripeWebhook.php` - Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` - Delete: `app/Listeners/DowngradeUserOnSubscriptionDeleted.php` - Delete: `tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php` - [ ] **Step 1: Append the failing test for the full deletion behaviour** Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: ```php use App\Models\UserNotificationPreference; 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(); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 4 tests, 1 failure (the new one — whatsapp pref still enabled). - [ ] **Step 3: Add the branch** Edit `app/Listeners/HandleStripeWebhook.php`: ```php 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', '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}"); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 4 passed - [ ] **Step 5: Delete the old listener and its test** ```bash rm app/Listeners/DowngradeUserOnSubscriptionDeleted.php rm tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php ``` - [ ] **Step 6: Run the full Payments test group to confirm nothing else broke** Run: `php artisan test --compact tests/Feature/Payments` Expected: all tests in that folder pass. - [ ] **Step 7: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Listeners tests/Feature/Payments git commit -m "feat: fold subscription deletion handling into HandleStripeWebhook" ``` --- ### Task 5: Add `invoice.payment_succeeded` branch **Files:** - Modify: `app/Listeners/HandleStripeWebhook.php` - Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` - [ ] **Step 1: Append the failing test** Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: ```php 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(); }); ``` - [ ] **Step 2: Run test to verify the first one fails** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 6 tests, 1 failure (grace_period_until still set). - [ ] **Step 3: Add the branch** In `app/Listeners/HandleStripeWebhook.php`, extend the `match`: ```php match ($type) { 'customer.subscription.created', 'customer.subscription.updated' => $this->bustPlanCache($user), 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), default => null, }; ``` Add the method below `handleSubscriptionDeleted`: ```php private function handlePaymentSucceeded(User $user): void { $user->forceFill(['grace_period_until' => null])->save(); $this->bustPlanCache($user); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 6 passed - [ ] **Step 5: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php git commit -m "feat: clear grace period on invoice.payment_succeeded" ``` --- ### Task 6: Create `PaymentFailedDay3Reminder` mailable + view **Files:** - Create: `app/Mail/PaymentFailedDay3Reminder.php` - Create: `resources/views/emails/payment-failed-day-3.blade.php` - [ ] **Step 1: Create the mailable skeleton** Run: `php artisan make:mail PaymentFailedDay3Reminder --view --no-interaction` This creates `app/Mail/PaymentFailedDay3Reminder.php` and `resources/views/mail/payment-failed-day-3-reminder.blade.php`. - [ ] **Step 2: Replace the mailable body** Overwrite `app/Mail/PaymentFailedDay3Reminder.php`: ```php $this->user->name, 'tier' => PlanFeatures::for($this->user)->tier(), 'portalUrl' => route('billing.portal'), ], ); } } ``` - [ ] **Step 3: Delete the default scaffold view and create the real one** ```bash rm resources/views/mail/payment-failed-day-3-reminder.blade.php rmdir resources/views/mail 2>/dev/null || true ``` Create `resources/views/emails/payment-failed-day-3.blade.php`: ```blade # 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 ``` - [ ] **Step 4: Confirm mail components are published** Run: `ls resources/views/vendor/mail 2>/dev/null | head -3` If empty, run: `php artisan vendor:publish --tag=laravel-mail --no-interaction` - [ ] **Step 5: Smoke-test the render** Run: `php artisan tinker --execute '(new App\Mail\PaymentFailedDay3Reminder(App\Models\User::factory()->make(["name" => "Test"])))->render();'` Expected: HTML output containing "Payment retry in progress" and a "Update payment method" link. - [ ] **Step 6: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Mail/PaymentFailedDay3Reminder.php resources/views/emails git commit -m "feat: add day-3 branded payment-failure reminder mailable" ``` --- ### Task 7: Create `PaymentFailedDay5Reminder` mailable + view **Files:** - Create: `app/Mail/PaymentFailedDay5Reminder.php` - Create: `resources/views/emails/payment-failed-day-5.blade.php` - [ ] **Step 1: Create the mailable skeleton** Run: `php artisan make:mail PaymentFailedDay5Reminder --view --no-interaction` - [ ] **Step 2: Replace the mailable body** Overwrite `app/Mail/PaymentFailedDay5Reminder.php`: ```php user)->tier(); return new Envelope( subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow', ); } public function content(): Content { return new Content( view: 'emails.payment-failed-day-5', with: [ 'name' => $this->user->name, 'tier' => PlanFeatures::for($this->user)->tier(), 'portalUrl' => route('billing.portal'), ], ); } } ``` - [ ] **Step 3: Delete the default scaffold view and create the real one** ```bash rm resources/views/mail/payment-failed-day-5-reminder.blade.php rmdir resources/views/mail 2>/dev/null || true ``` Create `resources/views/emails/payment-failed-day-5.blade.php`: ```blade # 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 ``` - [ ] **Step 4: Smoke-test the render** Run: `php artisan tinker --execute '(new App\Mail\PaymentFailedDay5Reminder(App\Models\User::factory()->make(["name" => "Test"])))->render();'` Expected: HTML output containing "Your Free features end tomorrow" (tier is `free` for a non-persisted user — acceptable for smoke test). - [ ] **Step 5: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Mail/PaymentFailedDay5Reminder.php resources/views/emails git commit -m "feat: add day-5 branded payment-failure reminder mailable" ``` --- ### Task 8: Create `SendPaymentFailedReminderJob` with grace guard **Files:** - Create: `app/Jobs/SendPaymentFailedReminderJob.php` - Test: `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php` - [ ] **Step 1: Write the failing test** Create `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php`: ```php 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(); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test --compact --filter=SendPaymentFailedReminderJobTest` Expected: FAIL — "Class App\\Jobs\\SendPaymentFailedReminderJob not found" - [ ] **Step 3: Create the job** Create `app/Jobs/SendPaymentFailedReminderJob.php`: ```php 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); } } ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test --compact --filter=SendPaymentFailedReminderJobTest` Expected: 5 passed - [ ] **Step 5: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Jobs/SendPaymentFailedReminderJob.php tests/Feature/Payments/SendPaymentFailedReminderJobTest.php git commit -m "feat: add SendPaymentFailedReminderJob with grace guard" ``` --- ### Task 9: Add `invoice.payment_failed` branch — set grace + dispatch reminders **Files:** - Modify: `app/Listeners/HandleStripeWebhook.php` - Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` - [ ] **Step 1: Append the failing test** Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: ```php use App\Jobs\SendPaymentFailedReminderJob; use Illuminate\Support\Facades\Queue; 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); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 7 tests, 1 failure (grace_period_until still null). - [ ] **Step 3: Add the branch** Edit `app/Listeners/HandleStripeWebhook.php`: Add imports at the top: ```php use App\Jobs\SendPaymentFailedReminderJob; use Illuminate\Support\Carbon; ``` Extend the `match`: ```php match ($type) { 'customer.subscription.created', '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, }; ``` Add the method below `handlePaymentSucceeded`: ```php private function handlePaymentFailed(User $user): void { $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); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test --compact --filter=HandleStripeWebhookTest` Expected: 7 passed - [ ] **Step 5: Run the full Payments test group** Run: `php artisan test --compact tests/Feature/Payments` Expected: all pass. - [ ] **Step 6: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php git commit -m "feat: handle invoice.payment_failed — set grace period and queue reminders" ``` --- ### Task 10: Add past-due banner to the dashboard **Files:** - Create: `resources/views/partials/past-due-banner.blade.php` - Modify: `resources/views/dashboard.blade.php` - [ ] **Step 1: Create the banner partial** Create `resources/views/partials/past-due-banner.blade.php`: ```blade @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 ``` - [ ] **Step 2: Include the banner in the dashboard** Edit `resources/views/dashboard.blade.php` — insert the `@include` directly inside the root `
`, above the grid: ```blade
@include('partials.past-due-banner')
``` (Leave the rest of the file untouched.) - [ ] **Step 3: Manually smoke-test the banner** Run: `php artisan tinker --execute '$u = App\Models\User::first(); $u->update(["grace_period_until" => now()->addDays(4)]);'` Then visit `https://fuel-price.test/dashboard` while logged in as that user. Expected: amber banner at the top of the dashboard linking to `/billing/portal`. Roll it back: Run: `php artisan tinker --execute 'App\Models\User::first()->update(["grace_period_until" => null]);'` Expected: banner disappears on refresh. - [ ] **Step 4: Run Pint and commit** ```bash vendor/bin/pint --dirty --format agent git add resources/views/partials resources/views/dashboard.blade.php git commit -m "feat: add past-due payment banner to dashboard" ``` --- ### Task 11: Final verification — full test suite + manual Stripe-mode checklist **Files:** none (verification only) - [ ] **Step 1: Run the full test suite** Run: `php artisan test --compact` Expected: all green. Note any failures and fix before proceeding. - [ ] **Step 2: Confirm manual Stripe dashboard config is scheduled** The following are **manual Stripe dashboard actions** — they are tracked as task #2 in the task list (`TaskList`) and must be done by the account owner before going live in production. For staging/test mode they can be deferred. Checklist (all in Stripe Dashboard → test mode first): 1. Billing → Automations → Subscription retry rules: - Switch from Smart Retries to **Custom** - Retry on days 1, 3, 5 after first failure - After final retry: **Cancel subscription** 2. Emails → Customer emails: enable *Successful payments*, *Failed payments*, *Upcoming renewals*. 3. Settings → Branding: upload FuelAlert logo + primary colour. 4. Customer Portal settings: allow plan changes (all 3 paid tiers × monthly + annual), allow cancellation at period end, allow card updates, allow invoice history. - [ ] **Step 3: Manual end-to-end test against Stripe test mode** Using Stripe test cards in `.claude/rules/payments.md`: 1. Sign up with card `4242 4242 4242 4242` on the Basic monthly plan → confirm dashboard shows Basic features. 2. Hit `/billing/portal`, upgrade to Pro → confirm `customer.subscription.updated` webhook arrives and Pro features become available immediately. 3. Create a subscription using card `4000 0000 0000 0341` (renewal charge fails) → fast-forward clock in Stripe CLI or wait for failed renewal → confirm: - Past-due banner appears on the dashboard - Day 3 reminder email is queued (visible in `php artisan queue:listen`) - Day 5 reminder email is queued - After Stripe's final retry fails, `customer.subscription.deleted` fires → user drops to free, WhatsApp + SMS prefs disabled, banner disappears. 4. Re-test with a working card mid-grace → `invoice.payment_succeeded` fires → banner disappears, queued day-3/day-5 reminders silently abort when they run. - [ ] **Step 4: Update the repo task list** Mark the two tasks from the earlier `TaskCreate` calls as completed: - Task #1 ("Build branded dunning reminder emails") — completed by Tasks 6 + 7 + 8 of this plan. - Task #2 ("Configure Stripe dunning email + retry schedule") — completed by Task 11, Step 2 (manual dashboard work). --- ## Self-Review Summary - **Spec coverage:** Every webhook branch, the grace column, both mailables, the job guard, dashboard banner, Stripe-dashboard config checklist, and the listener consolidation are covered by Tasks 1–11. - **Placeholders:** None — every code block is concrete. - **Type consistency:** `SendPaymentFailedReminderJob($userId, $day)` signature and the match on `$day === 3` / `5` are used consistently across Task 8 (job) and Task 9 (dispatch site). - **Idempotency:** The spec calls for idempotency across all webhook branches. Each branch uses operations that converge on the same final state (cache `forget`, `updateOrCreate`-style writes, `update` to static values). Queued reminders dispatched twice is the only edge case; accepted as tolerable since mailables self-cancel on `grace_period_until === null` and on the fresh-payment-failed event, the mailable will still send on the new grace window.