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 拦截