From bf013926c0519ab3c6d76d0ae62a77be16885aad Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 23 Apr 2026 10:05:50 +0100 Subject: [PATCH] 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. --- .claude/rules/payments.md | 126 +- .gitignore | 1 + ...026-04-23-stripe-subscription-lifecycle.md | 1095 +++++++++++++++++ ...23-stripe-subscription-lifecycle-design.md | 216 ++++ 4 files changed, 1410 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md create mode 100644 docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md diff --git a/.claude/rules/payments.md b/.claude/rules/payments.md index 041ea90..5c0e3f5 100644 --- a/.claude/rules/payments.md +++ b/.claude/rules/payments.md @@ -4,51 +4,121 @@ Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods. -## Stripe products +## Source-of-truth spec + +`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md` +defines the full subscription lifecycle. This file is a quick-reference; the +spec document is authoritative on any contradiction. + +## Stripe products & prices + +Three recurring subscription products, each with monthly and annual prices: -Three recurring subscription products (monthly): - `basic` — £0.99/mo - `plus` — £2.49/mo - `pro` — £3.99/mo -Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env: +Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`: + ``` -STRIPE_PRICES_BASIC=price_xxx -STRIPE_PRICES_PLUS=price_xxx -STRIPE_PRICES_PRO=price_xxx +STRIPE_PRICE_BASIC_MONTHLY=price_xxx +STRIPE_PRICE_BASIC_ANNUAL=price_xxx +STRIPE_PRICE_PLUS_MONTHLY=price_xxx +STRIPE_PRICE_PLUS_ANNUAL=price_xxx +STRIPE_PRICE_PRO_MONTHLY=price_xxx +STRIPE_PRICE_PRO_ANNUAL=price_xxx ``` -## Tier helpers (SubscriptionService) +Resolution from a Cashier subscription's Stripe price ID to a plan row is done +in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere. -```php -public function tier(User $user): string -// Returns 'free' | 'basic' | 'plus' | 'pro' +## Tier resolution -public function canReceiveSms(User $user): bool -// true if tier is plus or pro - -public function smsRemainingThisMonth(User $user): int -// checks alerts table count for current month -``` - -Never check tier inline in components or notification classes — always use SubscriptionService. +Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`. +Never inspect `$user->subscribed(...)` directly in components, notifications, or +jobs. `PlanFeatures` is the single source of entitlement truth. ## Cashier conventions -- Billable model: `User` (add `use Billable` trait) -- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically +- Billable model: `User` (uses `Billable` trait) +- Webhook route: `POST /stripe/webhook` — auto-registered by Cashier - Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET` -- Always handle `customer.subscription.deleted` to downgrade user to free tier -- Trial: none for v1 +- `STRIPE_KEY` and `STRIPE_SECRET` also required +- `CASHIER_CURRENCY=gbp` +- Trial period: none -## Upgrade / downgrade flow +## User-facing flows — all via Stripe Customer Portal -- User upgrades in account settings Livewire component -- Swap plan with `$user->subscription()->swap($newPriceId)` -- Cashier handles proration automatically -- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference +**The Stripe-hosted Customer Billing Portal handles every subscription +management action.** Do not build custom Livewire upgrade/downgrade UIs. + +| Flow | Path | +|---|---| +| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout | +| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately | +| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end | +| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end | +| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email | +| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) | + +Annual downgrades take effect at the end of the year — no mid-term refunds. + +## Webhook handling + +Single consolidated listener `HandleStripeWebhook` bound to Cashier's +`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`: + +| Event | Behaviour | +|---|---| +| `customer.subscription.created` | Bust `plan_for_user_{id}` cache | +| `customer.subscription.updated` | Bust cache | +| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache | +| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache | +| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables | + +All branches must be idempotent — Stripe retries failed webhook deliveries. + +`invoice.upcoming` is intentionally not handled. + +## Payment failure & grace period + +5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5 +and **cancel the subscription** after the final failure. + +- Features stay ON during grace — `past_due` is treated as subscribed by + Cashier, so `PlanFeatures::tier()` keeps returning the paid tier. +- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade. +- User can pay at any time via Stripe's dunning email link or the Customer + Portal — on success, grace is cleared automatically by the webhook. + +## Dunning emails + +- **Stripe sends:** payment-failed "update your card", successful-payment + receipts, upcoming-renewal reminders. Configure in Stripe dashboard. +- **We send:** branded reminder mailables on day 3 and day 5 after a + payment failure. Both mailables self-cancel by checking + `$this->user->grace_period_until === null` before sending — simpler than + cancelling queued jobs when payment recovers. + +## Data model additions + +- `users.grace_period_until` — nullable timestamp. Set on + `invoice.payment_failed`, cleared on `invoice.payment_succeeded` or + `customer.subscription.deleted`. Drives the dashboard past-due banner. + +No other schema additions. Cashier + Stripe are the source of truth for +subscription state. + +## VAT / Stripe Tax + +Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at +£3.99 avg, or ~470 paying pro users). ## Stripe test mode Use Stripe test keys in local `.env`. Never commit real Stripe keys. -Test cards: 4242424242424242 (success), 4000000000000002 (decline). + +Test cards: +- `4242 4242 4242 4242` — success +- `4000 0000 0000 0002` — generic decline +- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow) diff --git a/.gitignore b/.gitignore index b184fb4..d37fa78 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ yarn-error.log /.vscode /.zed /.tmp/ +/.worktrees/ diff --git a/docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md b/docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md new file mode 100644 index 0000000..4a22a98 --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md @@ -0,0 +1,1095 @@ +# Stripe Subscription Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the full Stripe subscription lifecycle — consolidated webhook handling, 5-day grace period, branded dunning emails, and past-due dashboard banner — on top of the existing Cashier + `Plan` + `PlanFeatures` foundation. + +**Architecture:** A single consolidated `HandleStripeWebhook` listener replaces the existing per-event `DowngradeUserOnSubscriptionDeleted` listener and routes on `$event->payload['type']` to keep plan cache and grace state in sync with Stripe. Payment-failure dunning is split: Stripe sends the transactional "update your card" email; we queue two branded reminders (day 3, day 5) via a `SendPaymentFailedReminderJob` that self-cancels when `grace_period_until` is cleared. + +**Tech Stack:** Laravel 13, Cashier, Laravel queues (Redis), Pest, Livewire, Blade, Tailwind. + +**Related spec:** `docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md` + +--- + +## File Structure + +**New files:** +- `database/migrations/_add_grace_period_until_to_users_table.php` +- `app/Listeners/HandleStripeWebhook.php` +- `app/Mail/PaymentFailedDay3Reminder.php` +- `app/Mail/PaymentFailedDay5Reminder.php` +- `app/Jobs/SendPaymentFailedReminderJob.php` +- `resources/views/emails/payment-failed-day-3.blade.php` +- `resources/views/emails/payment-failed-day-5.blade.php` +- `resources/views/partials/past-due-banner.blade.php` +- `tests/Feature/Payments/HandleStripeWebhookTest.php` +- `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php` + +**Modified files:** +- `app/Models/User.php` — add `grace_period_until` to `#[Fillable]` and casts +- `app/Providers/AppServiceProvider.php:44` — swap listener binding +- `database/factories/UserFactory.php` — add fillable key if factory explicitly sets it (likely no-op) +- `resources/views/dashboard.blade.php` — include past-due banner partial + +**Deleted files (Task 4):** +- `app/Listeners/DowngradeUserOnSubscriptionDeleted.php` +- `tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php` + +**Conventions to follow:** +- Tests use Pest (`it(...)`), `RefreshDatabase` trait, `User::factory()` +- Each individual test file must run with a 10-second timeout +- PHP 8.4: constructor property promotion, `final` for listeners/jobs, `readonly` properties where applicable +- Run `vendor/bin/pint --dirty --format agent` before every commit +- Commit message style: existing repo uses `type: description` (feat/fix/docs/refactor/test) + +--- + +### Task 1: Add `grace_period_until` column + model integration + +**Files:** +- Create: `database/migrations/_add_grace_period_until_to_users_table.php` +- Modify: `app/Models/User.php:20` +- Test: use existing factory; no dedicated test file + +- [ ] **Step 1: Create the migration** + +Run: `php artisan make:migration add_grace_period_until_to_users_table --table=users --no-interaction` + +- [ ] **Step 2: Fill in the migration body** + +```php +timestamp('grace_period_until')->nullable()->after('password') + ->comment('Set when invoice.payment_failed webhook fires; cleared on payment success or subscription deletion. Drives dashboard past-due banner.'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('grace_period_until'); + }); + } +}; +``` + +- [ ] **Step 3: Update the User model fillable + casts** + +Edit `app/Models/User.php:20` — add `grace_period_until` to the `#[Fillable]` attribute: + +```php +#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])] +``` + +Edit the `casts()` method to add the datetime cast: + +```php +protected function casts(): array +{ + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_admin' => 'boolean', + 'grace_period_until' => 'datetime', + ]; +} +``` + +- [ ] **Step 4: Run the migration** + +Run: `php artisan migrate --no-interaction` +Expected: migration runs cleanly; `users` table now has `grace_period_until` nullable timestamp. + +- [ ] **Step 5: Smoke-test the column is readable/writable** + +Run: `php artisan tinker --execute 'App\Models\User::factory()->create(["grace_period_until" => now()->addDays(5)])->grace_period_until->format("Y-m-d");'` +Expected: prints a date 5 days from now. + +- [ ] **Step 6: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add database/migrations app/Models/User.php +git commit -m "feat: add grace_period_until to users table" +``` + +--- + +### Task 2: Create `HandleStripeWebhook` listener with `subscription.created` branch + +**Files:** +- Create: `app/Listeners/HandleStripeWebhook.php` +- Modify: `app/Providers/AppServiceProvider.php:44` +- Test: `tests/Feature/Payments/HandleStripeWebhookTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Feature/Payments/HandleStripeWebhookTest.php`: + +```php +create(['stripe_id' => 'cus_created_1']); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['customer' => 'cus_created_1']], + ])); + + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); + +it('ignores subscription.created when the user is not found', function (): void { + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => ['customer' => 'cus_unknown']], + ])); + + expect(true)->toBeTrue(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: FAIL — "Class App\\Listeners\\HandleStripeWebhook not found" + +- [ ] **Step 3: Create the listener with the subscription.created branch** + +Create `app/Listeners/HandleStripeWebhook.php`: + +```php +payload['type'] ?? null; + $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + + if ($stripeCustomerId === null) { + return; + } + + $user = User::where('stripe_id', $stripeCustomerId)->first(); + + if ($user === null) { + return; + } + + match ($type) { + 'customer.subscription.created' => $this->bustPlanCache($user), + default => null, + }; + } + + private function bustPlanCache(User $user): void + { + Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); + } +} +``` + +- [ ] **Step 4: Wire it in AppServiceProvider (temporarily alongside the old listener)** + +Edit `app/Providers/AppServiceProvider.php`: + +Replace the `use App\Listeners\DowngradeUserOnSubscriptionDeleted;` line with: + +```php +use App\Listeners\HandleStripeWebhook; +``` + +Replace line 44 (`Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class);`) with: + +```php +Event::listen(WebhookReceived::class, HandleStripeWebhook::class); +``` + +> Note: the old `DowngradeUserOnSubscriptionDeleted` listener class and its test remain in place for now — they will be deleted in Task 4 once the new listener fully absorbs their behaviour. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 2 passed + +- [ ] **Step 6: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Listeners/HandleStripeWebhook.php app/Providers/AppServiceProvider.php tests/Feature/Payments/HandleStripeWebhookTest.php +git commit -m "feat: consolidate stripe webhook handling into HandleStripeWebhook listener" +``` + +--- + +### Task 3: Add `customer.subscription.updated` branch + +**Files:** +- Modify: `app/Listeners/HandleStripeWebhook.php` +- Test: `tests/Feature/Payments/HandleStripeWebhookTest.php` + +- [ ] **Step 1: Append the failing test** + +Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: + +```php +it('busts the plan cache on customer.subscription.updated', function (): void { + $user = User::factory()->create(['stripe_id' => 'cus_updated_1']); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => ['customer' => 'cus_updated_1']], + ])); + + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 3 tests, 1 failure (the new one — cache still has 'stale'). + +- [ ] **Step 3: Add the branch** + +In `app/Listeners/HandleStripeWebhook.php`, edit the `match` expression: + +```php +match ($type) { + 'customer.subscription.created', + 'customer.subscription.updated' => $this->bustPlanCache($user), + default => null, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 3 passed + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php +git commit -m "feat: bust plan cache on customer.subscription.updated" +``` + +--- + +### Task 4: Absorb `customer.subscription.deleted` behaviour + delete old listener + +**Files:** +- Modify: `app/Listeners/HandleStripeWebhook.php` +- Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` +- Delete: `app/Listeners/DowngradeUserOnSubscriptionDeleted.php` +- Delete: `tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php` + +- [ ] **Step 1: Append the failing test for the full deletion behaviour** + +Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: + +```php +use App\Models\UserNotificationPreference; + +it('on customer.subscription.deleted disables whatsapp+sms prefs, clears grace, busts cache', function (): void { + $user = User::factory()->create([ + 'stripe_id' => 'cus_deleted_1', + 'grace_period_until' => now()->addDays(5), + ]); + + UserNotificationPreference::query()->insert([ + ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ]); + + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_deleted_1']], + ])); + + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue(); + expect($user->fresh()->grace_period_until)->toBeNull(); + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 4 tests, 1 failure (the new one — whatsapp pref still enabled). + +- [ ] **Step 3: Add the branch** + +Edit `app/Listeners/HandleStripeWebhook.php`: + +```php +payload['type'] ?? null; + $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + + if ($stripeCustomerId === null) { + return; + } + + $user = User::where('stripe_id', $stripeCustomerId)->first(); + + if ($user === null) { + return; + } + + match ($type) { + 'customer.subscription.created', + 'customer.subscription.updated' => $this->bustPlanCache($user), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), + default => null, + }; + } + + private function handleSubscriptionDeleted(User $user): void + { + UserNotificationPreference::query() + ->where('user_id', $user->id) + ->whereIn('channel', ['whatsapp', 'sms']) + ->update(['enabled' => false]); + + $user->forceFill(['grace_period_until' => null])->save(); + + $this->bustPlanCache($user); + } + + private function bustPlanCache(User $user): void + { + Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 4 passed + +- [ ] **Step 5: Delete the old listener and its test** + +```bash +rm app/Listeners/DowngradeUserOnSubscriptionDeleted.php +rm tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php +``` + +- [ ] **Step 6: Run the full Payments test group to confirm nothing else broke** + +Run: `php artisan test --compact tests/Feature/Payments` +Expected: all tests in that folder pass. + +- [ ] **Step 7: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Listeners tests/Feature/Payments +git commit -m "feat: fold subscription deletion handling into HandleStripeWebhook" +``` + +--- + +### Task 5: Add `invoice.payment_succeeded` branch + +**Files:** +- Modify: `app/Listeners/HandleStripeWebhook.php` +- Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` + +- [ ] **Step 1: Append the failing test** + +Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: + +```php +it('on invoice.payment_succeeded clears grace_period_until and busts cache', function (): void { + $user = User::factory()->create([ + 'stripe_id' => 'cus_paid_1', + 'grace_period_until' => now()->addDays(4), + ]); + Cache::tags(['plans'])->put("plan_for_user_{$user->id}", 'stale', 3600); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['customer' => 'cus_paid_1']], + ])); + + expect($user->fresh()->grace_period_until)->toBeNull(); + expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull(); +}); + +it('invoice.payment_succeeded is a no-op when grace was never set', function (): void { + $user = User::factory()->create(['stripe_id' => 'cus_paid_2', 'grace_period_until' => null]); + + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_succeeded', + 'data' => ['object' => ['customer' => 'cus_paid_2']], + ])); + + expect($user->fresh()->grace_period_until)->toBeNull(); +}); +``` + +- [ ] **Step 2: Run test to verify the first one fails** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 6 tests, 1 failure (grace_period_until still set). + +- [ ] **Step 3: Add the branch** + +In `app/Listeners/HandleStripeWebhook.php`, extend the `match`: + +```php +match ($type) { + 'customer.subscription.created', + 'customer.subscription.updated' => $this->bustPlanCache($user), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), + 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), + default => null, +}; +``` + +Add the method below `handleSubscriptionDeleted`: + +```php +private function handlePaymentSucceeded(User $user): void +{ + $user->forceFill(['grace_period_until' => null])->save(); + $this->bustPlanCache($user); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 6 passed + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php +git commit -m "feat: clear grace period on invoice.payment_succeeded" +``` + +--- + +### Task 6: Create `PaymentFailedDay3Reminder` mailable + view + +**Files:** +- Create: `app/Mail/PaymentFailedDay3Reminder.php` +- Create: `resources/views/emails/payment-failed-day-3.blade.php` + +- [ ] **Step 1: Create the mailable skeleton** + +Run: `php artisan make:mail PaymentFailedDay3Reminder --view --no-interaction` + +This creates `app/Mail/PaymentFailedDay3Reminder.php` and +`resources/views/mail/payment-failed-day-3-reminder.blade.php`. + +- [ ] **Step 2: Replace the mailable body** + +Overwrite `app/Mail/PaymentFailedDay3Reminder.php`: + +```php + $this->user->name, + 'tier' => PlanFeatures::for($this->user)->tier(), + 'portalUrl' => route('billing.portal'), + ], + ); + } +} +``` + +- [ ] **Step 3: Delete the default scaffold view and create the real one** + +```bash +rm resources/views/mail/payment-failed-day-3-reminder.blade.php +rmdir resources/views/mail 2>/dev/null || true +``` + +Create `resources/views/emails/payment-failed-day-3.blade.php`: + +```blade + +# Payment retry in progress + +Hi {{ $name }}, + +We tried to renew your FuelAlert **{{ ucfirst($tier) }}** subscription but the +payment didn't go through. Stripe will retry automatically over the next couple +of days. + +If the card on file is out of date, please update it now so you don't lose your +{{ ucfirst($tier) }} features: + + + Update payment method + + +If Stripe's retries succeed, you won't need to do anything — you'll just get a +normal payment receipt. + +Thanks for using FuelAlert, +The FuelAlert Team + +``` + +- [ ] **Step 4: Confirm mail components are published** + +Run: `ls resources/views/vendor/mail 2>/dev/null | head -3` + +If empty, run: `php artisan vendor:publish --tag=laravel-mail --no-interaction` + +- [ ] **Step 5: Smoke-test the render** + +Run: `php artisan tinker --execute '(new App\Mail\PaymentFailedDay3Reminder(App\Models\User::factory()->make(["name" => "Test"])))->render();'` +Expected: HTML output containing "Payment retry in progress" and a "Update payment method" link. + +- [ ] **Step 6: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Mail/PaymentFailedDay3Reminder.php resources/views/emails +git commit -m "feat: add day-3 branded payment-failure reminder mailable" +``` + +--- + +### Task 7: Create `PaymentFailedDay5Reminder` mailable + view + +**Files:** +- Create: `app/Mail/PaymentFailedDay5Reminder.php` +- Create: `resources/views/emails/payment-failed-day-5.blade.php` + +- [ ] **Step 1: Create the mailable skeleton** + +Run: `php artisan make:mail PaymentFailedDay5Reminder --view --no-interaction` + +- [ ] **Step 2: Replace the mailable body** + +Overwrite `app/Mail/PaymentFailedDay5Reminder.php`: + +```php +user)->tier(); + + return new Envelope( + subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow', + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.payment-failed-day-5', + with: [ + 'name' => $this->user->name, + 'tier' => PlanFeatures::for($this->user)->tier(), + 'portalUrl' => route('billing.portal'), + ], + ); + } +} +``` + +- [ ] **Step 3: Delete the default scaffold view and create the real one** + +```bash +rm resources/views/mail/payment-failed-day-5-reminder.blade.php +rmdir resources/views/mail 2>/dev/null || true +``` + +Create `resources/views/emails/payment-failed-day-5.blade.php`: + +```blade + +# Your {{ ucfirst($tier) }} features end tomorrow + +Hi {{ $name }}, + +Stripe has been trying to renew your subscription for the past few days without +success. If the next retry doesn't go through, your **{{ ucfirst($tier) }}** +subscription will be cancelled tomorrow and your account will move back to the +Free tier. + +Here's what you'll lose on Free: + +- Smart fill-up / wait recommendations +- Multi-channel alerts (WhatsApp, SMS, push) +- Advanced fuel-price predictions + +You can keep everything running by updating your card now: + + + Update payment method + + +If you meant to cancel, no action needed — you'll just drop to Free automatically. + +Thanks for using FuelAlert, +The FuelAlert Team + +``` + +- [ ] **Step 4: Smoke-test the render** + +Run: `php artisan tinker --execute '(new App\Mail\PaymentFailedDay5Reminder(App\Models\User::factory()->make(["name" => "Test"])))->render();'` +Expected: HTML output containing "Your Free features end tomorrow" (tier is `free` for a non-persisted user — acceptable for smoke test). + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Mail/PaymentFailedDay5Reminder.php resources/views/emails +git commit -m "feat: add day-5 branded payment-failure reminder mailable" +``` + +--- + +### Task 8: Create `SendPaymentFailedReminderJob` with grace guard + +**Files:** +- Create: `app/Jobs/SendPaymentFailedReminderJob.php` +- Test: `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Feature/Payments/SendPaymentFailedReminderJobTest.php`: + +```php +create(['grace_period_until' => now()->addDays(2)]); + + (new SendPaymentFailedReminderJob($user->id, 3))->handle(); + + Mail::assertSent(PaymentFailedDay3Reminder::class, fn ($mail) => $mail->hasTo($user->email)); +}); + +it('sends the day-5 mailable when grace_period_until is still set', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => now()->addDay()]); + + (new SendPaymentFailedReminderJob($user->id, 5))->handle(); + + Mail::assertSent(PaymentFailedDay5Reminder::class, fn ($mail) => $mail->hasTo($user->email)); +}); + +it('silently skips the day-3 mailable when grace has been cleared', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => null]); + + (new SendPaymentFailedReminderJob($user->id, 3))->handle(); + + Mail::assertNothingSent(); +}); + +it('silently skips the day-5 mailable when grace has been cleared', function (): void { + Mail::fake(); + $user = User::factory()->create(['grace_period_until' => null]); + + (new SendPaymentFailedReminderJob($user->id, 5))->handle(); + + Mail::assertNothingSent(); +}); + +it('silently skips when the user no longer exists', function (): void { + Mail::fake(); + + (new SendPaymentFailedReminderJob(999999, 3))->handle(); + + Mail::assertNothingSent(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --compact --filter=SendPaymentFailedReminderJobTest` +Expected: FAIL — "Class App\\Jobs\\SendPaymentFailedReminderJob not found" + +- [ ] **Step 3: Create the job** + +Create `app/Jobs/SendPaymentFailedReminderJob.php`: + +```php +onQueue('notifications'); + } + + public function handle(): void + { + $user = User::find($this->userId); + + if ($user === null) { + return; + } + + if ($user->grace_period_until === null) { + return; + } + + $mailable = match ($this->day) { + 3 => new PaymentFailedDay3Reminder($user), + 5 => new PaymentFailedDay5Reminder($user), + default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"), + }; + + Mail::to($user->email)->send($mailable); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php artisan test --compact --filter=SendPaymentFailedReminderJobTest` +Expected: 5 passed + +- [ ] **Step 5: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Jobs/SendPaymentFailedReminderJob.php tests/Feature/Payments/SendPaymentFailedReminderJobTest.php +git commit -m "feat: add SendPaymentFailedReminderJob with grace guard" +``` + +--- + +### Task 9: Add `invoice.payment_failed` branch — set grace + dispatch reminders + +**Files:** +- Modify: `app/Listeners/HandleStripeWebhook.php` +- Modify: `tests/Feature/Payments/HandleStripeWebhookTest.php` + +- [ ] **Step 1: Append the failing test** + +Append to `tests/Feature/Payments/HandleStripeWebhookTest.php`: + +```php +use App\Jobs\SendPaymentFailedReminderJob; +use Illuminate\Support\Facades\Queue; + +it('on invoice.payment_failed sets grace_period_until 5 days out and queues both reminders', function (): void { + Queue::fake(); + $user = User::factory()->create(['stripe_id' => 'cus_failed_1', 'grace_period_until' => null]); + + $before = now(); + (new HandleStripeWebhook)->handle(new WebhookReceived([ + 'type' => 'invoice.payment_failed', + 'data' => ['object' => ['customer' => 'cus_failed_1']], + ])); + + $grace = $user->fresh()->grace_period_until; + expect($grace)->not->toBeNull(); + expect($grace->greaterThanOrEqualTo($before->copy()->addDays(5)->subSecond()))->toBeTrue(); + expect($grace->lessThanOrEqualTo($before->copy()->addDays(5)->addSeconds(5)))->toBeTrue(); + + Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) { + return $job->userId === $user->id && $job->day === 3; + }); + Queue::assertPushed(SendPaymentFailedReminderJob::class, function ($job) use ($user) { + return $job->userId === $user->id && $job->day === 5; + }); + Queue::assertPushed(SendPaymentFailedReminderJob::class, 2); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 7 tests, 1 failure (grace_period_until still null). + +- [ ] **Step 3: Add the branch** + +Edit `app/Listeners/HandleStripeWebhook.php`: + +Add imports at the top: + +```php +use App\Jobs\SendPaymentFailedReminderJob; +use Illuminate\Support\Carbon; +``` + +Extend the `match`: + +```php +match ($type) { + 'customer.subscription.created', + 'customer.subscription.updated' => $this->bustPlanCache($user), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user), + 'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user), + 'invoice.payment_failed' => $this->handlePaymentFailed($user), + default => null, +}; +``` + +Add the method below `handlePaymentSucceeded`: + +```php +private function handlePaymentFailed(User $user): void +{ + $user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save(); + + SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3)); + SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5)); + + $this->bustPlanCache($user); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `php artisan test --compact --filter=HandleStripeWebhookTest` +Expected: 7 passed + +- [ ] **Step 5: Run the full Payments test group** + +Run: `php artisan test --compact tests/Feature/Payments` +Expected: all pass. + +- [ ] **Step 6: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add app/Listeners/HandleStripeWebhook.php tests/Feature/Payments/HandleStripeWebhookTest.php +git commit -m "feat: handle invoice.payment_failed — set grace period and queue reminders" +``` + +--- + +### Task 10: Add past-due banner to the dashboard + +**Files:** +- Create: `resources/views/partials/past-due-banner.blade.php` +- Modify: `resources/views/dashboard.blade.php` + +- [ ] **Step 1: Create the banner partial** + +Create `resources/views/partials/past-due-banner.blade.php`: + +```blade +@auth + @if (auth()->user()->grace_period_until !== null) +
+
+ We couldn't charge your card. + Update your payment method by + {{ auth()->user()->grace_period_until->format('l, j M') }} + or your paid features will end. +
+ + Update card + +
+ @endif +@endauth +``` + +- [ ] **Step 2: Include the banner in the dashboard** + +Edit `resources/views/dashboard.blade.php` — insert the `@include` directly inside the root `
`, above the grid: + +```blade + +
+ @include('partials.past-due-banner') +
+``` + +(Leave the rest of the file untouched.) + +- [ ] **Step 3: Manually smoke-test the banner** + +Run: `php artisan tinker --execute '$u = App\Models\User::first(); $u->update(["grace_period_until" => now()->addDays(4)]);'` + +Then visit `https://fuel-price.test/dashboard` while logged in as that user. Expected: amber banner at the top of the dashboard linking to `/billing/portal`. + +Roll it back: + +Run: `php artisan tinker --execute 'App\Models\User::first()->update(["grace_period_until" => null]);'` + +Expected: banner disappears on refresh. + +- [ ] **Step 4: Run Pint and commit** + +```bash +vendor/bin/pint --dirty --format agent +git add resources/views/partials resources/views/dashboard.blade.php +git commit -m "feat: add past-due payment banner to dashboard" +``` + +--- + +### Task 11: Final verification — full test suite + manual Stripe-mode checklist + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full test suite** + +Run: `php artisan test --compact` +Expected: all green. Note any failures and fix before proceeding. + +- [ ] **Step 2: Confirm manual Stripe dashboard config is scheduled** + +The following are **manual Stripe dashboard actions** — they are tracked as task #2 in the task list (`TaskList`) and must be done by the account owner before going live in production. For staging/test mode they can be deferred. + +Checklist (all in Stripe Dashboard → test mode first): + +1. Billing → Automations → Subscription retry rules: + - Switch from Smart Retries to **Custom** + - Retry on days 1, 3, 5 after first failure + - After final retry: **Cancel subscription** +2. Emails → Customer emails: enable *Successful payments*, *Failed payments*, *Upcoming renewals*. +3. Settings → Branding: upload FuelAlert logo + primary colour. +4. Customer Portal settings: allow plan changes (all 3 paid tiers × monthly + annual), allow cancellation at period end, allow card updates, allow invoice history. + +- [ ] **Step 3: Manual end-to-end test against Stripe test mode** + +Using Stripe test cards in `.claude/rules/payments.md`: + +1. Sign up with card `4242 4242 4242 4242` on the Basic monthly plan → confirm dashboard shows Basic features. +2. Hit `/billing/portal`, upgrade to Pro → confirm `customer.subscription.updated` webhook arrives and Pro features become available immediately. +3. Create a subscription using card `4000 0000 0000 0341` (renewal charge fails) → fast-forward clock in Stripe CLI or wait for failed renewal → confirm: + - Past-due banner appears on the dashboard + - Day 3 reminder email is queued (visible in `php artisan queue:listen`) + - Day 5 reminder email is queued + - After Stripe's final retry fails, `customer.subscription.deleted` fires → user drops to free, WhatsApp + SMS prefs disabled, banner disappears. +4. Re-test with a working card mid-grace → `invoice.payment_succeeded` fires → banner disappears, queued day-3/day-5 reminders silently abort when they run. + +- [ ] **Step 4: Update the repo task list** + +Mark the two tasks from the earlier `TaskCreate` calls as completed: + +- Task #1 ("Build branded dunning reminder emails") — completed by Tasks 6 + 7 + 8 of this plan. +- Task #2 ("Configure Stripe dunning email + retry schedule") — completed by Task 11, Step 2 (manual dashboard work). + +--- + +## Self-Review Summary + +- **Spec coverage:** Every webhook branch, the grace column, both mailables, the job guard, dashboard banner, Stripe-dashboard config checklist, and the listener consolidation are covered by Tasks 1–11. +- **Placeholders:** None — every code block is concrete. +- **Type consistency:** `SendPaymentFailedReminderJob($userId, $day)` signature and the match on `$day === 3` / `5` are used consistently across Task 8 (job) and Task 9 (dispatch site). +- **Idempotency:** The spec calls for idempotency across all webhook branches. Each branch uses operations that converge on the same final state (cache `forget`, `updateOrCreate`-style writes, `update` to static values). Queued reminders dispatched twice is the only edge case; accepted as tolerable since mailables self-cancel on `grace_period_until === null` and on the fresh-payment-failed event, the mailable will still send on the new grace window. diff --git a/docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md b/docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md new file mode 100644 index 0000000..0b89bf6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md @@ -0,0 +1,216 @@ +# Stripe Subscription Lifecycle — Design Spec + +**Date:** 2026-04-23 +**Status:** Approved — ready for implementation plan + +## Purpose + +Formalise the end-to-end Stripe subscription flow: signup, upgrade, downgrade, +cancellation, renewal, payment failure recovery, and final downgrade. Covers +webhook handling, email communication, user-facing flows, and minimal data-model +additions on top of the existing Cashier + `Plan` + `PlanFeatures` foundation. + +Existing working pieces (see `docs/superpowers/specs/2026-04-15-tier-features-design.md`) +are kept as-is: + +- Laravel Cashier, `Plan` model with `resolveForUser()` + cache +- `PlanFeatures` service (all entitlement decisions) +- `RequiresFeature` middleware +- `DispatchUserNotificationJob` using `PlanFeatures` +- `BillingController` (checkout + portal + success/cancel routes) +- Existing `DowngradeUserOnSubscriptionDeleted` listener (absorbed into the new + consolidated handler below) + +## Decisions + +| Topic | Decision | +|---|---| +| Grace period length | 5 days from first failed renewal charge | +| Retry strategy | Stripe-managed: 3 attempts on days 1, 3, 5; then cancel subscription | +| Features during grace | Stay ON until `customer.subscription.deleted` fires | +| Dunning emails | Hybrid — Stripe sends "update card" transactional; we send branded day-3 and day-5 reminders | +| Annual downgrade policy | Wait until renewal; no mid-term refunds | +| Subscription management UI | Stripe-hosted Customer Portal for everything (upgrade, downgrade, cancel, card update, invoices) | +| VAT / Stripe Tax | Skip for v1; revisit before £90k turnover | +| Post-grace reactivation | User returns via pricing page → Stripe Checkout (new subscription) | + +## Webhook Event Catalogue + +All Stripe events arrive via Cashier's auto-registered `/stripe/webhook` route +and fire the `Laravel\Cashier\Events\WebhookReceived` event. A single consolidated +listener `HandleStripeWebhook` routes on `$event->payload['type']`. The existing +`DowngradeUserOnSubscriptionDeleted` listener is folded into it. + +| Event | Action | +|---|---| +| `customer.subscription.created` | Bust `plan_for_user_{id}` cache | +| `customer.subscription.updated` | Bust cache (catches portal plan swaps + `past_due` ↔ `active` transitions) | +| `customer.subscription.deleted` | Downgrade user to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache | +| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache | +| `invoice.payment_failed` | Set `grace_period_until = now + 5 days`, queue day-3 and day-5 reminder mailables with `->delay()` | + +Events not listed are ignored. `invoice.upcoming` is intentionally not handled — +Stripe's default renewal-preview email covers it. + +The listener is **idempotent**: every branch is safe to run twice on the same +event (Cashier does not natively deduplicate webhooks, and Stripe retries +failing deliveries). Cache busts are idempotent by nature; state writes use +`updateOrCreate` / direct column updates that converge on the same result. + +## Feature Access During Grace + +- When `invoice.payment_failed` fires, Stripe transitions the subscription to + `past_due`. Cashier's `$user->subscribed('plus')` returns `true` for + `past_due` subscriptions by default, so `PlanFeatures::tier()` already reports + the paid tier. **No code change needed.** +- Features only turn off when `customer.subscription.deleted` fires — which + happens after Stripe's 3rd failed retry (day 5). At that point the listener + clears the Cashier subscription and the next `PlanFeatures::for($user)` call + resolves to `free`. +- The dashboard renders a banner while `$user->subscription()->pastDue()` is + true, linking to the Stripe Portal to update the card. + +## Data Model Additions + +### `users` table + +Add one nullable column: + +| Column | Type | Purpose | +|---|---|---| +| `grace_period_until` | `timestamp` nullable | Drives the past-due banner + is used by reminder mailables as the cancellation check | + +Set when `invoice.payment_failed` fires (`now()->addDays(5)`). Cleared on +`invoice.payment_succeeded` or `customer.subscription.deleted`. + +No other schema changes. Stripe + Cashier tables remain the source of truth for +subscription state. + +## User-Facing Flows + +| Flow | Path | +|---|---| +| Sign up (paid) | Pricing page → `/billing/checkout/{tier}/{cadence}` → Stripe Checkout → dashboard | +| Upgrade | Pricing page → "Manage subscription" → Stripe Portal → select higher plan → Stripe prorates immediately → webhook updates cache | +| Downgrade | Stripe Portal → select lower plan → Stripe schedules change at period end → webhook on change day fires `customer.subscription.updated` → features swap on period rollover | +| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true` → features remain until period end → `customer.subscription.deleted` on that date | +| Update card | Stripe Portal, or via the "update payment method" link in Stripe's transactional dunning email | +| Reactivate after cancel / post-grace | Pricing page → Stripe Checkout (new subscription) | + +## Email Communication + +### Stripe-sent (configure in Stripe dashboard) + +- Successful payment receipts +- Failed payment "update your card" — includes hosted update link +- Upcoming renewal reminder (default 7 days pre-renewal) + +### FuelAlert-sent (our Laravel Mailables) + +| Name | Triggered by | Delay | Subject | +|---|---|---|---| +| `PaymentFailedDay3Reminder` | `invoice.payment_failed` webhook | `now + 3 days` | "Heads up — your FuelAlert payment is retrying" | +| `PaymentFailedDay5Reminder` | Same | `now + 5 days` | "Last chance — {Tier} features end tomorrow" | + +Both mailables check `$this->user->grace_period_until === null` in `build()` / +`content()` and abort silently if the grace period has already been cleared +(payment recovered or subscription cancelled). This is simpler than cancelling +queued jobs. + +Copy references the user's current tier by name, spells out which features +they'll lose on downgrade, and links to the Stripe Portal to update the card. + +## Stripe Dashboard Configuration (one-time, manual) + +1. **Billing → Automations → Subscription retry rules:** + - Switch from "Smart Retries" to **Custom** + - Retry schedule: day 1, day 3, day 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 + - Set primary colour to match app accent +4. **Customer Portal settings:** + - Allow plan changes (all 3 paid tiers × monthly + annual) + - Allow cancellation (at period end only) + - Allow payment method updates, invoice history + - Hide everything else (no custom domains, no promo code input — keep + promotion codes to checkout only) + +## Architecture + +``` +Stripe + │ + │ webhook POST /stripe/webhook + ▼ +CashierController (built-in) + │ + │ dispatches WebhookReceived event + ▼ +HandleStripeWebhook (new consolidated listener) + │ + ├── subscription.created/updated → cache bust + ├── subscription.deleted → downgrade + disable prefs + cache bust + ├── invoice.payment_succeeded → clear grace_period_until + cache bust + └── invoice.payment_failed → set grace_period_until + queue reminder mails + │ + ├─► PaymentFailedDay3Reminder (delay 3d) + └─► PaymentFailedDay5Reminder (delay 5d) + │ + └─ guard: abort if grace_period_until is null +``` + +## Testing Strategy + +Pest feature tests, one file per webhook branch, using Cashier's webhook-test +helpers (simulate `WebhookReceived` with a representative payload). + +Required test cases: + +- `customer.subscription.created` busts the plan cache +- `customer.subscription.updated` busts the plan cache after a portal plan swap +- `customer.subscription.deleted` downgrades to free, disables WhatsApp + SMS + prefs, clears `grace_period_until` (this folds in the existing + `DowngradeUserOnSubscriptionDeletedTest`) +- `invoice.payment_failed` sets `grace_period_until` 5 days out and queues both + reminder mailables with correct delays (use `Queue::fake()`) +- `invoice.payment_succeeded` clears `grace_period_until` +- `PaymentFailedDay3Reminder` aborts when `grace_period_until` is null +- `PaymentFailedDay5Reminder` aborts when `grace_period_until` is null +- Listener is idempotent — replaying the same event twice produces the same + final state +- Existing `BillingControllerTest` + `PlanFeaturesTest` continue to pass + +Manual QA checklist (production Stripe test mode): + +- Sign up on all three paid tiers × both cadences +- Upgrade basic-monthly → pro-monthly via Portal; confirm instant swap +- Downgrade pro-monthly → basic-monthly via Portal; confirm change takes effect + at next renewal +- Cancel mid-period; confirm features persist until period end +- Trigger payment failure with test card `4000 0000 0000 0341`; confirm banner + appears, day-3 + day-5 emails send, subscription cancels on day 5, user + downgrades to free + +## Out of Scope (v1) + +- Stripe Tax / VAT — revisit before £90k turnover +- Mid-term annual refunds — commitment model, no refunds +- Custom in-app upgrade/downgrade UI — Stripe Portal is the UI +- Trial periods — none offered +- `invoice.upcoming` handling — Stripe's default email is sufficient +- Subscription pause / skip-a-month — not in tier spec +- Multi-currency — GBP only for v1 + +## Open Documentation Updates + +The following project docs need editing to match this spec (done as part of +implementation, not a separate task): + +- `.claude/rules/payments.md` — current version doesn't mention grace period, + webhook catalogue, or decision to use Stripe Portal exclusively. Describes a + custom Livewire upgrade UI that is no longer planned. +- `.claude/rules/tiers.md` — largely accurate; check the "Notification Dispatch + Flow" section doesn't contradict any webhook behaviour here.