Files
fuel-price/docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.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

9.9 KiB
Raw Blame History

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_dueactive 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.