Files
gh-anton-abyzov-specweave-p…/commands/subscription-flow.md
2025-11-29 17:57:01 +08:00

1194 lines
32 KiB
Markdown

# /specweave-payments:subscription-flow
Complete subscription billing implementation guide with pricing tiers, trials, upgrades/downgrades, and lifecycle management.
You are a subscription billing expert who designs and implements SaaS recurring revenue systems.
## Your Task
Implement complete subscription billing with multiple tiers, trial periods, proration, cancellation handling, and customer portal.
### 1. Subscription Architecture
**Subscription Components**:
```
Product (e.g., "Pro Plan")
├─ Price (Monthly: $29)
├─ Price (Yearly: $290, 16% discount)
└─ Features (API access, 10 users, Priority support)
Customer
├─ Subscription (Active)
│ ├─ Items (Price ID, Quantity)
│ ├─ Current Period (Start/End)
│ └─ Payment Method (Card)
└─ Invoices (History)
```
**Subscription States**:
```
trialing → active → past_due → canceled
paused → resumed
incomplete → incomplete_expired
```
### 2. Product and Pricing Setup
**Define Pricing Tiers**:
```typescript
// src/config/subscription-plans.ts
export interface SubscriptionPlan {
id: string;
name: string;
description: string;
features: string[];
stripePriceIds: {
monthly: string;
yearly: string;
};
prices: {
monthly: number; // in cents
yearly: number;
};
limits: {
users?: number;
apiCalls?: number;
storage?: number; // in GB
};
popular?: boolean;
}
export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [
{
id: 'free',
name: 'Free',
description: 'Perfect for trying out our service',
features: [
'Up to 2 users',
'1,000 API calls/month',
'1 GB storage',
'Community support',
],
stripePriceIds: {
monthly: '', // No Stripe price for free tier
yearly: '',
},
prices: {
monthly: 0,
yearly: 0,
},
limits: {
users: 2,
apiCalls: 1000,
storage: 1,
},
},
{
id: 'starter',
name: 'Starter',
description: 'Great for small teams getting started',
features: [
'Up to 5 users',
'10,000 API calls/month',
'10 GB storage',
'Email support',
'Basic analytics',
],
stripePriceIds: {
monthly: 'price_starter_monthly_xxx',
yearly: 'price_starter_yearly_xxx',
},
prices: {
monthly: 2900, // $29
yearly: 29000, // $290 (16% discount)
},
limits: {
users: 5,
apiCalls: 10000,
storage: 10,
},
},
{
id: 'pro',
name: 'Pro',
description: 'For growing teams with advanced needs',
features: [
'Up to 20 users',
'100,000 API calls/month',
'100 GB storage',
'Priority support',
'Advanced analytics',
'Custom integrations',
],
stripePriceIds: {
monthly: 'price_pro_monthly_xxx',
yearly: 'price_pro_yearly_xxx',
},
prices: {
monthly: 9900, // $99
yearly: 99000, // $990 (16% discount)
},
limits: {
users: 20,
apiCalls: 100000,
storage: 100,
},
popular: true,
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'Custom solutions for large organizations',
features: [
'Unlimited users',
'Unlimited API calls',
'Unlimited storage',
'Dedicated support',
'SLA guarantees',
'Custom contracts',
'On-premise deployment',
],
stripePriceIds: {
monthly: 'price_enterprise_monthly_xxx',
yearly: 'price_enterprise_yearly_xxx',
},
prices: {
monthly: 49900, // $499
yearly: 499000, // $4,990 (16% discount)
},
limits: {
users: undefined, // unlimited
apiCalls: undefined,
storage: undefined,
},
},
];
export function getPlanById(planId: string): SubscriptionPlan | undefined {
return SUBSCRIPTION_PLANS.find((plan) => plan.id === planId);
}
export function getPlanByPriceId(priceId: string): SubscriptionPlan | undefined {
return SUBSCRIPTION_PLANS.find(
(plan) =>
plan.stripePriceIds.monthly === priceId ||
plan.stripePriceIds.yearly === priceId
);
}
```
### 3. Subscription Service
**Subscription Management**:
```typescript
// src/services/subscription.service.ts
import { stripe } from '../config/stripe';
import type Stripe from 'stripe';
import { getPlanById } from '../config/subscription-plans';
export class SubscriptionService {
/**
* Create a subscription with trial period
*/
async createSubscription(params: {
customerId: string;
priceId: string;
trialDays?: number;
quantity?: number;
couponId?: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Subscription> {
try {
const subscriptionParams: Stripe.SubscriptionCreateParams = {
customer: params.customerId,
items: [
{
price: params.priceId,
quantity: params.quantity || 1,
},
],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
metadata: params.metadata,
};
// Add trial period if specified
if (params.trialDays && params.trialDays > 0) {
subscriptionParams.trial_period_days = params.trialDays;
}
// Add coupon if specified
if (params.couponId) {
subscriptionParams.coupon = params.couponId;
}
const subscription = await stripe.subscriptions.create(subscriptionParams);
return subscription;
} catch (error) {
console.error('Failed to create subscription:', error);
throw new Error('Subscription creation failed');
}
}
/**
* Create subscription with checkout session
*/
async createSubscriptionCheckout(params: {
customerId?: string;
customerEmail?: string;
priceId: string;
trialDays?: number;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Checkout.Session> {
try {
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
line_items: [
{
price: params.priceId,
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
};
// Customer reference
if (params.customerId) {
sessionParams.customer = params.customerId;
} else if (params.customerEmail) {
sessionParams.customer_email = params.customerEmail;
}
// Trial period
if (params.trialDays && params.trialDays > 0) {
sessionParams.subscription_data = {
trial_period_days: params.trialDays,
};
}
const session = await stripe.checkout.sessions.create(sessionParams);
return session;
} catch (error) {
console.error('Failed to create subscription checkout:', error);
throw new Error('Checkout creation failed');
}
}
/**
* Retrieve subscription details
*/
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['customer', 'default_payment_method', 'latest_invoice'],
});
} catch (error) {
console.error('Failed to retrieve subscription:', error);
throw new Error('Subscription retrieval failed');
}
}
/**
* Update subscription (upgrade/downgrade)
*/
async updateSubscription(params: {
subscriptionId: string;
newPriceId: string;
prorationBehavior?: 'create_prorations' | 'none' | 'always_invoice';
quantity?: number;
}): Promise<Stripe.Subscription> {
try {
// Get current subscription
const subscription = await stripe.subscriptions.retrieve(params.subscriptionId);
// Update subscription
const updated = await stripe.subscriptions.update(params.subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: params.newPriceId,
quantity: params.quantity,
},
],
proration_behavior: params.prorationBehavior || 'create_prorations',
});
return updated;
} catch (error) {
console.error('Failed to update subscription:', error);
throw new Error('Subscription update failed');
}
}
/**
* Cancel subscription (immediate or at period end)
*/
async cancelSubscription(params: {
subscriptionId: string;
immediately?: boolean;
cancellationReason?: string;
}): Promise<Stripe.Subscription> {
try {
if (params.immediately) {
// Cancel immediately
return await stripe.subscriptions.cancel(params.subscriptionId, {
cancellation_details: {
comment: params.cancellationReason,
},
});
} else {
// Cancel at period end
return await stripe.subscriptions.update(params.subscriptionId, {
cancel_at_period_end: true,
cancellation_details: {
comment: params.cancellationReason,
},
});
}
} catch (error) {
console.error('Failed to cancel subscription:', error);
throw new Error('Subscription cancellation failed');
}
}
/**
* Resume a canceled subscription
*/
async resumeSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
} catch (error) {
console.error('Failed to resume subscription:', error);
throw new Error('Subscription resume failed');
}
}
/**
* Pause subscription
*/
async pauseSubscription(params: {
subscriptionId: string;
resumeAt?: number; // Unix timestamp
}): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.update(params.subscriptionId, {
pause_collection: {
behavior: 'void',
resumes_at: params.resumeAt,
},
});
} catch (error) {
console.error('Failed to pause subscription:', error);
throw new Error('Subscription pause failed');
}
}
/**
* Resume paused subscription
*/
async unpauseSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.update(subscriptionId, {
pause_collection: null as any,
});
} catch (error) {
console.error('Failed to unpause subscription:', error);
throw new Error('Subscription unpause failed');
}
}
/**
* List customer subscriptions
*/
async listCustomerSubscriptions(
customerId: string
): Promise<Stripe.Subscription[]> {
try {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
expand: ['data.default_payment_method'],
});
return subscriptions.data;
} catch (error) {
console.error('Failed to list subscriptions:', error);
throw new Error('Subscription listing failed');
}
}
/**
* Get upcoming invoice (preview charges)
*/
async getUpcomingInvoice(params: {
customerId: string;
subscriptionId: string;
newPriceId?: string;
}): Promise<Stripe.Invoice> {
try {
const invoiceParams: Stripe.InvoiceRetrieveUpcomingParams = {
customer: params.customerId,
subscription: params.subscriptionId,
};
// Preview plan change
if (params.newPriceId) {
const subscription = await stripe.subscriptions.retrieve(params.subscriptionId);
invoiceParams.subscription_items = [
{
id: subscription.items.data[0].id,
price: params.newPriceId,
},
];
}
return await stripe.invoices.retrieveUpcoming(invoiceParams);
} catch (error) {
console.error('Failed to retrieve upcoming invoice:', error);
throw new Error('Invoice preview failed');
}
}
/**
* Create customer portal session
*/
async createPortalSession(params: {
customerId: string;
returnUrl: string;
}): Promise<Stripe.BillingPortal.Session> {
try {
return await stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
} catch (error) {
console.error('Failed to create portal session:', error);
throw new Error('Portal session creation failed');
}
}
/**
* Apply coupon to subscription
*/
async applyCoupon(
subscriptionId: string,
couponId: string
): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.update(subscriptionId, {
coupon: couponId,
});
} catch (error) {
console.error('Failed to apply coupon:', error);
throw new Error('Coupon application failed');
}
}
/**
* Remove coupon from subscription
*/
async removeCoupon(subscriptionId: string): Promise<Stripe.Subscription> {
try {
return await stripe.subscriptions.update(subscriptionId, {
coupon: '',
});
} catch (error) {
console.error('Failed to remove coupon:', error);
throw new Error('Coupon removal failed');
}
}
}
export const subscriptionService = new SubscriptionService();
```
### 4. API Routes
**Subscription Endpoints**:
```typescript
// src/routes/subscription.routes.ts
import { Router, Request, Response } from 'express';
import { subscriptionService } from '../services/subscription.service';
const router = Router();
/**
* POST /api/subscriptions
* Create a subscription
*/
router.post('/', async (req: Request, res: Response) => {
try {
const { customerId, priceId, trialDays, quantity, couponId, metadata } = req.body;
if (!customerId || !priceId) {
return res.status(400).json({ error: 'Customer ID and Price ID required' });
}
const subscription = await subscriptionService.createSubscription({
customerId,
priceId,
trialDays,
quantity,
couponId,
metadata,
});
res.json({
subscriptionId: subscription.id,
status: subscription.status,
clientSecret: (subscription.latest_invoice as any)?.payment_intent
?.client_secret,
});
} catch (error) {
console.error('Subscription creation error:', error);
res.status(500).json({ error: 'Failed to create subscription' });
}
});
/**
* POST /api/subscriptions/checkout
* Create subscription checkout session
*/
router.post('/checkout', async (req: Request, res: Response) => {
try {
const { customerId, customerEmail, priceId, trialDays, metadata } = req.body;
if (!priceId) {
return res.status(400).json({ error: 'Price ID required' });
}
const session = await subscriptionService.createSubscriptionCheckout({
customerId,
customerEmail,
priceId,
trialDays,
successUrl: `${req.headers.origin}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${req.headers.origin}/subscription/cancel`,
metadata,
});
res.json({ sessionId: session.id, url: session.url });
} catch (error) {
console.error('Checkout creation error:', error);
res.status(500).json({ error: 'Failed to create checkout' });
}
});
/**
* GET /api/subscriptions/:id
* Get subscription details
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const subscription = await subscriptionService.getSubscription(req.params.id);
res.json(subscription);
} catch (error) {
console.error('Subscription retrieval error:', error);
res.status(500).json({ error: 'Failed to retrieve subscription' });
}
});
/**
* PATCH /api/subscriptions/:id
* Update subscription (upgrade/downgrade)
*/
router.patch('/:id', async (req: Request, res: Response) => {
try {
const { newPriceId, quantity, prorationBehavior } = req.body;
if (!newPriceId) {
return res.status(400).json({ error: 'New price ID required' });
}
const subscription = await subscriptionService.updateSubscription({
subscriptionId: req.params.id,
newPriceId,
quantity,
prorationBehavior,
});
res.json(subscription);
} catch (error) {
console.error('Subscription update error:', error);
res.status(500).json({ error: 'Failed to update subscription' });
}
});
/**
* DELETE /api/subscriptions/:id
* Cancel subscription
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const { immediately, reason } = req.body;
const subscription = await subscriptionService.cancelSubscription({
subscriptionId: req.params.id,
immediately,
cancellationReason: reason,
});
res.json(subscription);
} catch (error) {
console.error('Subscription cancellation error:', error);
res.status(500).json({ error: 'Failed to cancel subscription' });
}
});
/**
* POST /api/subscriptions/:id/resume
* Resume canceled subscription
*/
router.post('/:id/resume', async (req: Request, res: Response) => {
try {
const subscription = await subscriptionService.resumeSubscription(req.params.id);
res.json(subscription);
} catch (error) {
console.error('Subscription resume error:', error);
res.status(500).json({ error: 'Failed to resume subscription' });
}
});
/**
* POST /api/subscriptions/:id/pause
* Pause subscription
*/
router.post('/:id/pause', async (req: Request, res: Response) => {
try {
const { resumeAt } = req.body;
const subscription = await subscriptionService.pauseSubscription({
subscriptionId: req.params.id,
resumeAt,
});
res.json(subscription);
} catch (error) {
console.error('Subscription pause error:', error);
res.status(500).json({ error: 'Failed to pause subscription' });
}
});
/**
* POST /api/subscriptions/:id/unpause
* Resume paused subscription
*/
router.post('/:id/unpause', async (req: Request, res: Response) => {
try {
const subscription = await subscriptionService.unpauseSubscription(req.params.id);
res.json(subscription);
} catch (error) {
console.error('Subscription unpause error:', error);
res.status(500).json({ error: 'Failed to unpause subscription' });
}
});
/**
* GET /api/subscriptions/customer/:customerId
* List customer subscriptions
*/
router.get('/customer/:customerId', async (req: Request, res: Response) => {
try {
const subscriptions = await subscriptionService.listCustomerSubscriptions(
req.params.customerId
);
res.json(subscriptions);
} catch (error) {
console.error('Subscription listing error:', error);
res.status(500).json({ error: 'Failed to list subscriptions' });
}
});
/**
* GET /api/subscriptions/:id/upcoming-invoice
* Preview upcoming invoice
*/
router.get('/:id/upcoming-invoice', async (req: Request, res: Response) => {
try {
const { customerId, newPriceId } = req.query;
if (!customerId) {
return res.status(400).json({ error: 'Customer ID required' });
}
const invoice = await subscriptionService.getUpcomingInvoice({
customerId: customerId as string,
subscriptionId: req.params.id,
newPriceId: newPriceId as string,
});
res.json(invoice);
} catch (error) {
console.error('Invoice preview error:', error);
res.status(500).json({ error: 'Failed to preview invoice' });
}
});
/**
* POST /api/subscriptions/portal
* Create customer portal session
*/
router.post('/portal', async (req: Request, res: Response) => {
try {
const { customerId } = req.body;
if (!customerId) {
return res.status(400).json({ error: 'Customer ID required' });
}
const session = await subscriptionService.createPortalSession({
customerId,
returnUrl: `${req.headers.origin}/account`,
});
res.json({ url: session.url });
} catch (error) {
console.error('Portal session error:', error);
res.status(500).json({ error: 'Failed to create portal session' });
}
});
/**
* POST /api/subscriptions/:id/coupon
* Apply coupon to subscription
*/
router.post('/:id/coupon', async (req: Request, res: Response) => {
try {
const { couponId } = req.body;
if (!couponId) {
return res.status(400).json({ error: 'Coupon ID required' });
}
const subscription = await subscriptionService.applyCoupon(
req.params.id,
couponId
);
res.json(subscription);
} catch (error) {
console.error('Coupon application error:', error);
res.status(500).json({ error: 'Failed to apply coupon' });
}
});
/**
* DELETE /api/subscriptions/:id/coupon
* Remove coupon from subscription
*/
router.delete('/:id/coupon', async (req: Request, res: Response) => {
try {
const subscription = await subscriptionService.removeCoupon(req.params.id);
res.json(subscription);
} catch (error) {
console.error('Coupon removal error:', error);
res.status(500).json({ error: 'Failed to remove coupon' });
}
});
export default router;
```
### 5. Frontend Components
**Pricing Table**:
```typescript
// src/components/PricingTable.tsx
import React from 'react';
import { SUBSCRIPTION_PLANS } from '../config/subscription-plans';
interface PricingTableProps {
billingCycle: 'monthly' | 'yearly';
onSelectPlan: (planId: string, priceId: string) => void;
}
export const PricingTable: React.FC<PricingTableProps> = ({
billingCycle,
onSelectPlan,
}) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 p-6">
{SUBSCRIPTION_PLANS.map((plan) => (
<div
key={plan.id}
className={`
relative border rounded-lg p-6 flex flex-col
${plan.popular ? 'border-blue-500 shadow-lg' : 'border-gray-200'}
`}
>
{plan.popular && (
<span className="absolute top-0 right-0 bg-blue-500 text-white text-xs px-3 py-1 rounded-bl-lg rounded-tr-lg">
Popular
</span>
)}
<h3 className="text-2xl font-bold text-gray-900">{plan.name}</h3>
<p className="mt-2 text-gray-600 text-sm">{plan.description}</p>
<div className="mt-6">
<span className="text-4xl font-bold text-gray-900">
${plan.prices[billingCycle] / 100}
</span>
<span className="text-gray-600">/{billingCycle === 'yearly' ? 'year' : 'month'}</span>
{billingCycle === 'yearly' && plan.prices.yearly > 0 && (
<p className="text-sm text-green-600 mt-1">
Save ${(plan.prices.monthly * 12 - plan.prices.yearly) / 100}/year
</p>
)}
</div>
<ul className="mt-6 space-y-3 flex-grow">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start">
<svg
className="w-5 h-5 text-green-500 mr-2 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-gray-700 text-sm">{feature}</span>
</li>
))}
</ul>
<button
onClick={() =>
onSelectPlan(plan.id, plan.stripePriceIds[billingCycle])
}
disabled={plan.id === 'free'}
className={`
mt-6 w-full py-3 px-4 rounded-lg font-medium transition-colors
${
plan.popular
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
}
${plan.id === 'free' ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{plan.id === 'free' ? 'Current Plan' : 'Get Started'}
</button>
</div>
))}
</div>
);
};
```
**Subscription Management**:
```typescript
// src/components/SubscriptionManager.tsx
import React, { useState, useEffect } from 'react';
import type Stripe from 'stripe';
interface SubscriptionManagerProps {
customerId: string;
}
export const SubscriptionManager: React.FC<SubscriptionManagerProps> = ({
customerId,
}) => {
const [subscriptions, setSubscriptions] = useState<Stripe.Subscription[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSubscriptions();
}, [customerId]);
const loadSubscriptions = async () => {
try {
const response = await fetch(`/api/subscriptions/customer/${customerId}`);
const data = await response.json();
setSubscriptions(data);
} catch (error) {
console.error('Failed to load subscriptions:', error);
} finally {
setLoading(false);
}
};
const handleCancelSubscription = async (subscriptionId: string) => {
if (!confirm('Are you sure you want to cancel this subscription?')) {
return;
}
try {
await fetch(`/api/subscriptions/${subscriptionId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ immediately: false }),
});
await loadSubscriptions();
alert('Subscription will be canceled at the end of the billing period');
} catch (error) {
console.error('Failed to cancel subscription:', error);
alert('Failed to cancel subscription');
}
};
const handleResumeSubscription = async (subscriptionId: string) => {
try {
await fetch(`/api/subscriptions/${subscriptionId}/resume`, {
method: 'POST',
});
await loadSubscriptions();
alert('Subscription resumed successfully');
} catch (error) {
console.error('Failed to resume subscription:', error);
alert('Failed to resume subscription');
}
};
const handleManageBilling = async () => {
try {
const response = await fetch('/api/subscriptions/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId }),
});
const { url } = await response.json();
window.location.href = url;
} catch (error) {
console.error('Failed to open billing portal:', error);
alert('Failed to open billing portal');
}
};
if (loading) {
return <div>Loading subscriptions...</div>;
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Your Subscriptions</h2>
<button
onClick={handleManageBilling}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
Manage Billing
</button>
</div>
<div className="space-y-4">
{subscriptions.map((subscription) => (
<div
key={subscription.id}
className="border border-gray-200 rounded-lg p-6"
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">
{subscription.items.data[0].price.product as string}
</h3>
<p className="text-gray-600 mt-1">
${subscription.items.data[0].price.unit_amount! / 100}/
{subscription.items.data[0].price.recurring?.interval}
</p>
<p className="text-sm text-gray-500 mt-2">
Status:{' '}
<span
className={`
font-medium
${subscription.status === 'active' ? 'text-green-600' : ''}
${subscription.status === 'trialing' ? 'text-blue-600' : ''}
${subscription.status === 'past_due' ? 'text-red-600' : ''}
`}
>
{subscription.status}
</span>
</p>
{subscription.cancel_at_period_end && (
<p className="text-sm text-orange-600 mt-1">
Cancels on{' '}
{new Date(subscription.current_period_end * 1000).toLocaleDateString()}
</p>
)}
</div>
<div className="flex gap-2">
{subscription.cancel_at_period_end ? (
<button
onClick={() => handleResumeSubscription(subscription.id)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Resume
</button>
) : (
<button
onClick={() => handleCancelSubscription(subscription.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Cancel
</button>
)}
</div>
</div>
</div>
))}
{subscriptions.length === 0 && (
<p className="text-gray-600 text-center py-8">
You don't have any active subscriptions
</p>
)}
</div>
</div>
);
};
```
### 6. Webhook Handling
**Subscription Events**:
```typescript
// src/webhooks/subscription.webhook.ts
import type Stripe from 'stripe';
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
): Promise<void> {
console.log('Subscription created:', subscription.id);
// Update database
// await db.subscriptions.create({
// stripeSubscriptionId: subscription.id,
// customerId: subscription.customer,
// status: subscription.status,
// currentPeriodEnd: new Date(subscription.current_period_end * 1000),
// });
// Send welcome email
}
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
): Promise<void> {
console.log('Subscription updated:', subscription.id);
// Update database
// await db.subscriptions.update({
// where: { stripeSubscriptionId: subscription.id },
// data: {
// status: subscription.status,
// currentPeriodEnd: new Date(subscription.current_period_end * 1000),
// },
// });
// Handle status changes
if (subscription.status === 'past_due') {
// Send payment failed email
}
}
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
): Promise<void> {
console.log('Subscription deleted:', subscription.id);
// Update database
// await db.subscriptions.update({
// where: { stripeSubscriptionId: subscription.id },
// data: {
// status: 'canceled',
// canceledAt: new Date(),
// },
// });
// Revoke access
// Send cancellation confirmation email
}
export async function handleInvoicePaymentSucceeded(
invoice: Stripe.Invoice
): Promise<void> {
console.log('Invoice payment succeeded:', invoice.id);
// Record payment
// Send receipt
}
export async function handleInvoicePaymentFailed(
invoice: Stripe.Invoice
): Promise<void> {
console.log('Invoice payment failed:', invoice.id);
// Send payment failed notification
// Implement dunning management
}
```
## Output Deliverables
When you complete this implementation, provide:
1. **Configuration**:
- Subscription plans with pricing tiers
- Stripe product and price IDs
- Trial period settings
2. **Backend Services**:
- Subscription service with all operations
- API routes for subscription management
- Webhook handlers for subscription events
3. **Frontend Components**:
- Pricing table with plan comparison
- Subscription management dashboard
- Plan upgrade/downgrade UI
4. **Documentation**:
- Subscription lifecycle diagram
- Upgrade/downgrade flow
- Proration explanation
- Cancellation policy
5. **Testing**:
- Subscription creation tests
- Plan change tests
- Cancellation tests
- Trial period tests
## Best Practices
1. **Always use proration** for mid-cycle changes
2. **Implement trials** to reduce friction
3. **Allow cancellation at period end** (not immediately)
4. **Use customer portal** for self-service
5. **Send clear email notifications** for all subscription events
6. **Handle failed payments gracefully** with retry logic
7. **Preview charges** before plan changes
8. **Track subscription metrics** (MRR, churn, LTV)
9. **Offer annual discounts** to improve retention
10. **Make downgrades easy** to reduce immediate cancellations
Subscriptions are the foundation of SaaS revenue. Implement them robustly with clear communication and excellent UX.