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.
9.9 KiB
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,
Planmodel withresolveForUser()+ cache PlanFeaturesservice (all entitlement decisions)RequiresFeaturemiddlewareDispatchUserNotificationJobusingPlanFeaturesBillingController(checkout + portal + success/cancel routes)- Existing
DowngradeUserOnSubscriptionDeletedlistener (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_due ↔ active 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_failedfires, Stripe transitions the subscription topast_due. Cashier's$user->subscribed('plus')returnstrueforpast_duesubscriptions by default, soPlanFeatures::tier()already reports the paid tier. No code change needed. - Features only turn off when
customer.subscription.deletedfires — which happens after Stripe's 3rd failed retry (day 5). At that point the listener clears the Cashier subscription and the nextPlanFeatures::for($user)call resolves tofree. - 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)
- 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
- Emails → Customer emails:
- Enable: "Successful payments", "Failed payments", "Upcoming renewals"
- Settings → Branding:
- Upload FuelAlert logo
- Set primary colour to match app accent
- 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.createdbusts the plan cachecustomer.subscription.updatedbusts the plan cache after a portal plan swapcustomer.subscription.deleteddowngrades to free, disables WhatsApp + SMS prefs, clearsgrace_period_until(this folds in the existingDowngradeUserOnSubscriptionDeletedTest)invoice.payment_failedsetsgrace_period_until5 days out and queues both reminder mailables with correct delays (useQueue::fake())invoice.payment_succeededclearsgrace_period_untilPaymentFailedDay3Reminderaborts whengrace_period_untilis nullPaymentFailedDay5Reminderaborts whengrace_period_untilis null- Listener is idempotent — replaying the same event twice produces the same final state
- Existing
BillingControllerTest+PlanFeaturesTestcontinue 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.upcominghandling — 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.