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.
217 lines
9.9 KiB
Markdown
217 lines
9.9 KiB
Markdown
# 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.
|