Captures the agreed design for Stripe webhook handling, 5-day grace period with branded day-3/day-5 reminders, and Stripe Customer Portal as the single subscription-management surface. Updates payments rules to match and ignores .worktrees/ for isolated implementation work.
35 KiB
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/<timestamp>_add_grace_period_until_to_users_table.phpapp/Listeners/HandleStripeWebhook.phpapp/Mail/PaymentFailedDay3Reminder.phpapp/Mail/PaymentFailedDay5Reminder.phpapp/Jobs/SendPaymentFailedReminderJob.phpresources/views/emails/payment-failed-day-3.blade.phpresources/views/emails/payment-failed-day-5.blade.phpresources/views/partials/past-due-banner.blade.phptests/Feature/Payments/HandleStripeWebhookTest.phptests/Feature/Payments/SendPaymentFailedReminderJobTest.php
Modified files:
app/Models/User.php— addgrace_period_untilto#[Fillable]and castsapp/Providers/AppServiceProvider.php:44— swap listener bindingdatabase/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.phptests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php
Conventions to follow:
- Tests use Pest (
it(...)),RefreshDatabasetrait,User::factory() - Each individual test file must run with a 10-second timeout
- PHP 8.4: constructor property promotion,
finalfor listeners/jobs,readonlyproperties where applicable - Run
vendor/bin/pint --dirty --format agentbefore 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/<timestamp>_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
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('users', function (Blueprint $table) {
$table->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:
#[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:
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
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
use App\Listeners\HandleStripeWebhook;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
uses(RefreshDatabase::class);
it('busts the plan cache on customer.subscription.created', function (): void {
$user = User::factory()->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
namespace App\Listeners;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
final class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
$type = $event->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:
use App\Listeners\HandleStripeWebhook;
Replace line 44 (Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);) with:
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
Note: the old
DowngradeUserOnSubscriptionDeletedlistener 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
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:
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:
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
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:
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
namespace App\Listeners;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
final class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
$type = $event->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
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
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:
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:
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:
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
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
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay3Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Heads up — your FuelAlert payment is retrying',
);
}
public function content(): Content
{
return new Content(
view: 'emails.payment-failed-day-3',
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
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:
<x-mail::message>
# 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:
<x-mail::button :url="$portalUrl">
Update payment method
</x-mail::button>
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
</x-mail::message>
- 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
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
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay5Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
$tier = PlanFeatures::for($this->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
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:
<x-mail::message>
# 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:
<x-mail::button :url="$portalUrl">
Update payment method
</x-mail::button>
If you meant to cancel, no action needed — you'll just drop to Free automatically.
Thanks for using FuelAlert,
The FuelAlert Team
</x-mail::message>
- 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
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
use App\Jobs\SendPaymentFailedReminderJob;
use App\Mail\PaymentFailedDay3Reminder;
use App\Mail\PaymentFailedDay5Reminder;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
it('sends the day-3 mailable when grace_period_until is still set', function (): void {
Mail::fake();
$user = User::factory()->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
namespace App\Jobs;
use App\Mail\PaymentFailedDay3Reminder;
use App\Mail\PaymentFailedDay5Reminder;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use InvalidArgumentException;
final class SendPaymentFailedReminderJob implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly int $userId,
public readonly int $day,
) {
$this->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
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:
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:
use App\Jobs\SendPaymentFailedReminderJob;
use Illuminate\Support\Carbon;
Extend the match:
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:
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
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:
@auth
@if (auth()->user()->grace_period_until !== null)
<div class="mb-4 flex items-center justify-between gap-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-amber-900 dark:text-amber-100">
<div class="flex-1 text-sm">
<strong class="font-semibold">We couldn't charge your card.</strong>
Update your payment method by
{{ auth()->user()->grace_period_until->format('l, j M') }}
or your paid features will end.
</div>
<a
href="{{ route('billing.portal') }}"
class="shrink-0 rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
>
Update card
</a>
</div>
@endif
@endauth
- Step 2: Include the banner in the dashboard
Edit resources/views/dashboard.blade.php — insert the @include directly inside the root <div>, above the grid:
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
@include('partials.past-due-banner')
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
(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
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):
- 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
- Emails → Customer emails: enable Successful payments, Failed payments, Upcoming renewals.
- Settings → Branding: upload FuelAlert logo + primary colour.
- 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:
- Sign up with card
4242 4242 4242 4242on the Basic monthly plan → confirm dashboard shows Basic features. - Hit
/billing/portal, upgrade to Pro → confirmcustomer.subscription.updatedwebhook arrives and Pro features become available immediately. - 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.deletedfires → user drops to free, WhatsApp + SMS prefs disabled, banner disappears.
- Re-test with a working card mid-grace →
invoice.payment_succeededfires → 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/5are 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,updateto static values). Queued reminders dispatched twice is the only edge case; accepted as tolerable since mailables self-cancel ongrace_period_until === nulland on the fresh-payment-failed event, the mailable will still send on the new grace window.