Files
fuel-price/docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md
Ovidiu U bf013926c0 docs: add stripe subscription lifecycle spec + implementation plan
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.
2026-04-23 10:05:50 +01:00

35 KiB
Raw Blame History

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.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/<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 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
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):

  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 111.
  • 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.