支付集成
支付方案选择
SaaS 产品的支付方案主要有两类:
| 方案 | 代表产品 | 模式 | 适用场景 |
|---|---|---|---|
| 支付处理器 | Stripe | 你是商家,自己处理税务 | 全球市场、灵活定价 |
| 商家记录 | Paddle, LemonSqueezy | 他们是商家,帮你处理税务 | 简化合规、欧洲市场 |
Stripe vs Paddle
Stripe:
- 费率:2.9% + $0.30(美国)
- 你负责税务合规
- 完全控制定价和计费逻辑
- 适合:美国市场、技术团队强
Paddle:
- 费率:5% + $0.50
- 他们处理全球税务
- 简化的集成
- 适合:欧洲市场、独立开发者
本教程以 Stripe 为例,因为它是最灵活的方案。
Stripe 集成
1. 创建 Stripe 账户
- 访问 stripe.com 注册
- 完成业务验证
- 获取 API 密钥
2. 安装依赖
bash
pnpm add stripe @stripe/stripe-js3. 配置环境变量
env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...4. 创建 Stripe 客户端
typescript
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
})订阅模式实现
1. 在 Stripe 创建产品和价格
可以在 Stripe Dashboard 手动创建,或用代码:
typescript
// scripts/create-products.ts
import { stripe } from '@/lib/stripe'
async function createProducts() {
// 创建产品
const product = await stripe.products.create({
name: 'Pro Plan',
description: '专业版订阅',
})
// 创建月付价格
await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: 'usd',
recurring: { interval: 'month' },
})
// 创建年付价格
await stripe.prices.create({
product: product.id,
unit_amount: 29000, // $290.00 (省 2 个月)
currency: 'usd',
recurring: { interval: 'year' },
})
}2. 创建 Checkout Session
typescript
// app/api/checkout/route.ts
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const session = await auth()
if (!session?.user) {
return new NextResponse('未授权', { status: 401 })
}
const { priceId } = await request.json()
// 获取或创建 Stripe 客户
const user = await db.user.findUnique({
where: { id: session.user.id },
include: { subscription: true }
})
let customerId = user?.subscription?.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: { userId: session.user.id }
})
customerId = customer.id
}
// 创建 Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: { userId: session.user.id }
})
return NextResponse.json({ url: checkoutSession.url })
}3. 定价页面
tsx
// app/(marketing)/pricing/page.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
const plans = [
{
name: '免费版',
price: 0,
features: ['基础功能', '1 个项目', '社区支持'],
priceId: null,
},
{
name: '专业版',
price: 29,
features: ['所有功能', '无限项目', '优先支持', 'API 访问'],
priceId: 'price_xxx', // 替换为真实 ID
popular: true,
},
{
name: '团队版',
price: 99,
features: ['专业版所有功能', '团队协作', '高级分析', '专属客服'],
priceId: 'price_yyy',
},
]
export default function PricingPage() {
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubscribe(priceId: string) {
setLoading(true)
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
router.push(url)
}
return (
<div className="py-24">
<h1 className="text-4xl font-bold text-center">选择适合你的方案</h1>
<div className="mt-16 grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{plans.map((plan) => (
<div
key={plan.name}
className={`p-8 rounded-lg border ${
plan.popular ? 'border-blue-500 ring-2 ring-blue-500' : ''
}`}
>
{plan.popular && (
<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm">
最受欢迎
</span>
)}
<h3 className="text-xl font-bold mt-4">{plan.name}</h3>
<p className="text-4xl font-bold mt-2">
${plan.price}<span className="text-lg">/月</span>
</p>
<ul className="mt-6 space-y-3">
{plan.features.map((feature) => (
<li key={feature}>✓ {feature}</li>
))}
</ul>
<button
onClick={() => plan.priceId && handleSubscribe(plan.priceId)}
disabled={!plan.priceId || loading}
className="mt-8 w-full py-2 bg-black text-white rounded"
>
{plan.priceId ? '立即订阅' : '免费开始'}
</button>
</div>
))}
</div>
</div>
)
}Webhook 处理
Stripe 通过 Webhook 通知你支付状态变化。这是最关键的部分。
创建 Webhook 端点
typescript
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(request: Request) {
const body = await request.text()
const signature = headers().get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Webhook 签名验证失败', { status: 400 })
}
// 处理不同事件
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCancel(event.data.object)
break
}
return new Response('OK', { status: 200 })
}
async function handleCheckoutComplete(session: any) {
const userId = session.metadata.userId
const subscriptionId = session.subscription
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
await db.subscription.upsert({
where: { userId },
create: {
userId,
stripeCustomerId: session.customer,
stripeSubscriptionId: subscriptionId,
stripePriceId: subscription.items.data[0].price.id,
status: 'active',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscriptionId,
stripePriceId: subscription.items.data[0].price.id,
status: 'active',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
}
async function handleSubscriptionUpdate(subscription: any) {
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
}
async function handleSubscriptionCancel(subscription: any) {
await db.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: { status: 'canceled' },
})
}本地测试 Webhook
bash
# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
# 登录
stripe login
# 转发 Webhook 到本地
stripe listen --forward-to localhost:3000/api/webhooks/stripe客户门户
让用户自己管理订阅(升级、降级、取消)。
typescript
// app/api/billing/portal/route.ts
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
import { NextResponse } from 'next/server'
export async function POST() {
const session = await auth()
if (!session?.user) {
return new NextResponse('未授权', { status: 401 })
}
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id }
})
if (!subscription?.stripeCustomerId) {
return new NextResponse('未找到订阅', { status: 404 })
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings`,
})
return NextResponse.json({ url: portalSession.url })
}检查订阅状态
typescript
// lib/subscription.ts
import { auth } from './auth'
import { db } from './db'
export async function getUserSubscription() {
const session = await auth()
if (!session?.user) return null
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id }
})
const isActive = subscription?.status === 'active' &&
subscription.currentPeriodEnd &&
subscription.currentPeriodEnd > new Date()
return {
...subscription,
isActive,
}
}
// 在页面中使用
export async function isPro() {
const subscription = await getUserSubscription()
return subscription?.isActive ?? false
}下一步
支付系统完成后,准备部署上线。