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.
1096 lines
35 KiB
Markdown
1096 lines
35 KiB
Markdown
# 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
|
||
<?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:
|
||
|
||
```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
|
||
<?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
|
||
<?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:
|
||
|
||
```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
|
||
<?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**
|
||
|
||
```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
|
||
<?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**
|
||
|
||
```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
|
||
<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**
|
||
|
||
```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
|
||
<?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**
|
||
|
||
```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
|
||
<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**
|
||
|
||
```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
|
||
<?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
|
||
<?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**
|
||
|
||
```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)
|
||
<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:
|
||
|
||
```blade
|
||
<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**
|
||
|
||
```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.
|