SaaS Billing: Subscription Plans
Billing is critical for SaaS. Stripe is standard: Products, Prices, Subscriptions, Webhooks. Developer's job is to link business logic to Stripe events and not lose money from missed webhooks.
Stripe Data Structure
Product "Pro Plan"
├── Price (monthly): $29/month recurring
└── Price (annual): $290/year recurring
Product "Enterprise Plan"
├── Price (monthly): $99/month recurring
└── Price (annual): $990/year recurring
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'For growing teams',
metadata: { plan: 'pro' },
});
const monthlyPrice = await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: 2900, // $29.00
recurring: {
interval: 'month',
},
});
const annualPrice = await stripe.prices.create({
product: product.id,
currency: 'usd',
unit_amount: 29000, // $290.00
recurring: {
interval: 'year',
},
});
Data Schema
model Subscription {
id String @id @default(cuid())
tenantId String @unique
stripeCustomerId String @unique
stripeSubscriptionId String? @unique
stripePriceId String?
plan Plan @default(FREE)
status SubscriptionStatus @default(ACTIVE)
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false)
canceledAt DateTime?
trialEnd DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id])
}
enum Plan { FREE, STARTER, PRO, ENTERPRISE }
enum SubscriptionStatus { ACTIVE, PAST_DUE, CANCELED, PAUSED, TRIALING }
Checkout: Creating Subscription
export async function POST(request: Request) {
const session = await auth();
const { priceId, successUrl, cancelUrl } = await request.json();
const tenant = await getCurrentTenant();
const subscription = await db.subscription.findUnique({
where: { tenantId: tenant!.id }
});
let customerId = subscription?.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: session!.user.email!,
name: tenant!.name,
metadata: { tenantId: tenant!.id },
});
customerId = customer.id;
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: cancelUrl,
subscription_data: {
trial_period_days: 14,
metadata: { tenantId: tenant!.id },
},
allow_promotion_codes: true,
});
return Response.json({ url: checkoutSession.url });
}
Webhook: Event Handling
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
const processed = await db.stripeEvent.findUnique({
where: { stripeEventId: event.id }
});
if (processed) return Response.json({ received: true });
await db.stripeEvent.create({ data: { stripeEventId: event.id } });
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenantId;
const plan = getPlanFromPrice(subscription.items.data[0].price.id);
await db.subscription.upsert({
where: { tenantId },
create: {
tenantId,
stripeCustomerId: subscription.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
plan,
status: mapStripeStatus(subscription.status),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
plan,
status: mapStripeStatus(subscription.status),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: { status: 'CANCELED', canceledAt: new Date() }
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await sendPaymentFailedEmail(invoice.customer_email!);
break;
}
}
return Response.json({ received: true });
}
Plan Limit Checking
const PLAN_LIMITS = {
FREE: { projects: 3, members: 1, storageGb: 1 },
STARTER: { projects: 10, members: 5, storageGb: 10 },
PRO: { projects: 50, members: 20, storageGb: 100 },
ENTERPRISE: { projects: Infinity, members: Infinity, storageGb: 1000 },
};
export async function checkProjectLimit(tenantId: string) {
const [subscription, projectCount] = await Promise.all([
db.subscription.findUnique({ where: { tenantId } }),
db.project.count({ where: { tenantId } }),
]);
const plan = subscription?.plan ?? 'FREE';
const limit = PLAN_LIMITS[plan].projects;
if (projectCount >= limit) {
throw new Error(`Project limit reached (${limit} for ${plan} plan)`);
}
}
Stripe billing setup with plans, checkout flow and webhook handler — 3–5 working days.







