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