Commerce
Subscription Billing
Recurring payments on a schedule (monthly, yearly), with self-serve plan changes, cancellations, and dunning when payments fail.
When to use this
For SaaS, content subscriptions, memberships, or any recurring-revenue model. Always use a billing provider (Stripe Billing, Paddle), never roll your own.
What I assumed
I made these guesses to fill gaps. Let me know if any are wrong.
Flow diagram
Step-by-step recipe
Copy this and paste into Cursor, Claude Code, or v0.
PATTERN: Subscription Billing
INPUT: user, plan_id (e.g., "pro_monthly"), payment_method
OUTPUT: active_subscription | billing_error
SUBSCRIBE_STEPS:
1. User picks a plan from pricing page
2. Show summary: plan, billing interval, first charge amount, trial details if any
3. Collect payment method (composable_with: checkout)
4. Create customer + subscription in billing provider (Stripe Subscription)
5. IF trial โ mark active with trial_end date, no charge yet
6. IF no trial โ first charge happens immediately
7. Provider sends webhook: subscription.created
8. Server updates user record: tier, status, current_period_end
9. Grant access to features for this tier
10. Send welcome email + invoice
BILLING_CYCLE_STEPS (recurring, automatic):
1. Provider attempts charge on current_period_end
2. IF succeeds โ extend current_period_end, send invoice
3. IF fails โ enter dunning (retry 3x over 14 days)
4. IF dunning exhausted โ cancel subscription, downgrade access
5. Send appropriate email at each step (success, retry, final-warning)
SELF_SERVICE_STEPS:
1. User opens billing portal
2. Can: change plan, update card, cancel, view invoices
3. Plan changes: prorate immediately or at period end (your choice)
4. Cancellation: keep access until current_period_end, then downgrade
ERROR_HANDLING:
- Charge fails (insufficient funds, expired card) โ enter dunning, email user
- User updates card mid-dunning โ retry immediately
- Webhook missed โ daily reconciliation job syncs subscription state
- Plan change race condition โ use idempotency keys
EXTENSION_POINTS:
- Coupon for first month/year (composable_with: ["coupon"])
- Refund partial period on cancel (composable_with: ["refund"])
- Dunning emails sequence (composable_with: ["dunning"])
- Annual upgrade prompt for monthly users
States โ how things change
| State | Description | Transitions |
|---|---|---|
| Trial | Active without charge |
|
| Active | Paid and current |
|
| Past due | In dunning retry cycle |
|
| Cancel pending | Will end at period_end, access kept until then |
|
| Cancelled | Access removed | terminal |
Easy-to-miss situations
The kinds of edge cases that break demos.
What if a user cancels but wants to come back later?
mediumLose data and history if you delete on cancel.
Suggested handling: Soft-cancel โ keep account and data. Re-subscribing restores access seamlessly. Show "Welcome back!" message. Keep data 12+ months before hard-delete.
What if a user upgrades mid-cycle?
mediumHow much to charge for the prorated period?
Suggested handling: Use Stripe's automatic proration. Charge difference for remaining days. Email immediate invoice showing the calculation transparently.
What if billing fails on a paying customer's expired card?
highLose paying customer if you don't recover gracefully.
Suggested handling: Email 7 days BEFORE card expires. On failure, send dunning emails over 14 days (3 retries). Provide one-click "Update card" link. Don't immediately cancel.
What if a user's currency changes (moved countries)?
lowPricing might be off, payment method invalid in new country.
Suggested handling: Detect via IP + payment method country. Offer to switch billing currency on next renewal. Don't force change mid-period.
What if a user is double-billed due to webhook race?
highTrust catastrophe, immediate refund + apology required.
Suggested handling: Use idempotency keys on every billing operation. Daily reconciliation job catches doubles. If detected, auto-refund + email apology before user notices.
Composes well with
Combine these patterns when you need a richer flow.