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.
This commit is contained in:
Ovidiu U
2026-04-23 10:05:50 +01:00
parent 19fc61a0a3
commit bf013926c0
4 changed files with 1410 additions and 28 deletions

View File

@@ -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.