Stripe 支付与订阅计费
SaaS 的命脉是 MRR——Stripe 是订阅计费的行业标准。本章讲解 Next.js + Stripe 完整集成。
1. Stripe 核心概念
1.1 对象模型
| 对象 | 说明 | 对应 SaaS 概念 |
|---|
| Product | 产品 | SaaS 套餐(Free/Pro/Enterprise) |
| Price | 价格 | 月付 $10 / 年付 $100 |
| Customer | 客户 | 一个组织/租户 |
| Subscription | 订阅 | 客户与套餐的绑定 |
| Invoice | 发票 | 每期账单 |
1.2 计费模型
| 模型 | 示例 |
|---|
| 固定订阅 | $10/月 Pro 套餐 |
| 按量计费 | $0.01/次 API 调用 |
| 座位计费 | $10/用户/月 |
| 混合 | $10/月含 1000 次,超出 $0.01/次 |
1.3 集成流程
关键原则:永远通过 Webhook 更新订阅状态——不要信任客户端回调。
用户点击"升级" → Server Action 创建 Checkout Session
→ 重定向 Stripe 页面 → 用户支付 → Stripe Webhook
→ API Route 更新数据库 → 用户看到新套餐
2. Stripe 基础集成
2.1 初始化
// lib/stripe/index.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
})
2.2 创建 Customer
// lib/stripe/customer.ts
export async function getOrCreateStripeCustomer(tenantId: string) {
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
if (tenant.stripeCustomerId) return tenant.stripeCustomerId
const customer = await stripe.customers.create({
metadata: { tenantId },
name: tenant.name,
})
await db.update(tenants)
.set({ stripeCustomerId: customer.id })
.where(eq(tenants.id, tenantId))
return customer.id
}
2.3 定义套餐
// lib/stripe/plans.ts
export const PLANS = {
free: { name: 'Free', limits: { projects: 3, members: 2 } },
pro: {
name: 'Pro',
stripePriceId: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
},
limits: { projects: 50, members: 10 },
},
enterprise: {
name: 'Enterprise',
stripePriceId: {
monthly: process.env.STRIPE_ENT_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_ENT_YEARLY_PRICE_ID!,
},
limits: { projects: Infinity, members: Infinity },
},
} as const
3. Checkout 与 Customer Portal
3.1 创建 Checkout Session
// app/(dashboard)/billing/actions.ts
'use server'
export async function createCheckoutSession(priceId: string) {
const { membership } = await requirePermission('billing:manage')
const customerId = await getOrCreateStripeCustomer(membership.tenantId)
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,
metadata: { tenantId: membership.tenantId },
automatic_tax: { enabled: true },
allow_promotion_codes: true,
})
redirect(session.url!)
}
3.2 Customer Portal
让用户自助管理订阅(升降级、更换支付方式、取消、查看发票):
export async function createPortalSession() {
const { membership } = await requirePermission('billing:manage')
const customerId = await getOrCreateStripeCustomer(membership.tenantId)
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
})
redirect(session.url)
}
4. Webhook 处理
4.1 关键事件
| 事件 | 你需要做什么 |
|---|
checkout.session.completed | 激活订阅 |
invoice.paid | 续期订阅 |
invoice.payment_failed | 通知用户更新支付方式 |
customer.subscription.updated | 更新套餐 |
customer.subscription.deleted | 降级到 Free |
4.2 Webhook Route
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const body = await request.text()
const signature = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body, signature, process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object)
break
}
return NextResponse.json({ received: true })
}
4.3 事件处理
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const tenantId = session.metadata?.tenantId
if (!tenantId) return
const sub = await stripe.subscriptions.retrieve(session.subscription as string)
const plan = getPlanByPriceId(sub.items.data[0].price.id)
await db.insert(subscriptions).values({
tenantId,
stripeSubscriptionId: sub.id,
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
}).onConflictDoUpdate({
target: subscriptions.tenantId,
set: { stripeSubscriptionId: sub.id, status: sub.status },
})
await db.update(tenants).set({ plan }).where(eq(tenants.id, tenantId))
}
4.4 Webhook 可靠性
| 问题 | 对策 |
|---|
| 重复投递 | 用 event.id 做幂等处理 |
| 乱序 | 用 event.created 判断新旧 |
| 超时 | 5 秒内返回 200,长任务用队列 |
| 本地测试 | stripe listen --forward-to localhost:3000/api/webhooks/stripe |
5. 计量计费与发票
5.1 上报用量
export async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
})
}
5.2 功能限制检查
// lib/stripe/limits.ts
export async function checkLimit(tenantId: string, resource: 'projects' | 'members') {
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId))
const plan = PLANS[tenant.plan as PlanKey]
const currentCount = await getResourceCount(tenantId, resource)
if (currentCount >= plan.limits[resource]) {
throw new Error(`Plan limit reached: upgrade to add more ${resource}`)
}
}
5.3 定价页组件数据
// app/(marketing)/pricing/page.tsx
const tiers = [
{ name: 'Free', price: '$0', features: ['3 projects', '2 members', 'Community support'] },
{ name: 'Pro', price: '$12/mo', features: ['50 projects', '10 members', 'Priority support'], popular: true },
{ name: 'Enterprise', price: '$49/mo', features: ['Unlimited', 'SSO', 'Dedicated support'] },
]
本章小结
- 对象模型:Customer → Subscription → Invoice,Customer 与 Tenant 一对一映射
- Checkout:Server Action 创建 Session → 重定向 Stripe 托管页 → Webhook 更新状态
- Customer Portal:Stripe 托管的订阅管理页面,零开发成本
- Webhook:签名验证 + 幂等处理 + 5 秒超时内返回,是计费系统的核心
- 计量计费:
createUsageRecord 上报用量,Stripe 自动汇总出账
- 功能限制:PLANS 集中定义 limits,Server Action 中
checkLimit 拦截