Files
fuel-price/.claude/rules/payments.md
Ovidiu U bf013926c0 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.
2026-04-23 10:05:50 +01:00

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/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)