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.
4.8 KiB
Payments & Subscriptions
Stack
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
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:
basic— £0.99/moplus— £2.49/mopro— £3.99/mo
Price IDs stored in config/services.php under stripe.prices.*, loaded from .env:
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
Resolution from a Cashier subscription's Stripe price ID to a plan row is done
in Plan::resolveForUser() — never hand-code tier lookups elsewhere.
Tier resolution
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(usesBillabletrait) - Webhook route:
POST /stripe/webhook— auto-registered by Cashier - Webhook secret in
.envasSTRIPE_WEBHOOK_SECRET STRIPE_KEYandSTRIPE_SECRETalso requiredCASHIER_CURRENCY=gbp- Trial period: none
User-facing flows — all via Stripe Customer Portal
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_dueis treated as subscribed by Cashier, soPlanFeatures::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 === nullbefore sending — simpler than cancelling queued jobs when payment recovers.
Data model additions
users.grace_period_until— nullable timestamp. Set oninvoice.payment_failed, cleared oninvoice.payment_succeededorcustomer.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:
4242 4242 4242 4242— success4000 0000 0000 0002— generic decline4000 0000 0000 0341— renewal charge fails (use to test dunning flow)