# 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/mo - `plus` — £2.49/mo - `pro` — £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` (uses `Billable` trait) - Webhook route: `POST /stripe/webhook` — auto-registered by Cashier - Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET` - `STRIPE_KEY` and `STRIPE_SECRET` also required - `CASHIER_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_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: - `4242 4242 4242 4242` — success - `4000 0000 0000 0002` — generic decline - `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)