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:
@@ -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.
|
||||
Reference in New Issue
Block a user