计费与订阅
多服务商计费支持 - Creem、Stripe、LemonSqueezy 和 Airwallex,订阅、一次性支付和 Webhook
计费与订阅
ProductReady 使用 @billing - 一个多服务商计费抽象层,支持 Creem(默认)、Stripe、LemonSqueezy 和 Airwallex,提供统一的 API。
默认服务商:Creem - ProductReady 默认使用 Creem 作为计费服务商。Creem 是商户记录服务商(MoR),自动处理税务、合规和全球支付。
快速概览
支持的服务商:
- ✅ Creem - 商户记录,全球支付(默认)
- ✅ Stripe - 行业标准(推荐用于美国/欧盟)
- ✅ LemonSqueezy - 商户记录(处理税务/合规)
- ✅ Airwallex - 适合亚太地区
主要特性:
- 🔒 类型安全 - 完整的 TypeScript 支持和 Zod 验证
- 🎯 服务商抽象 - 所有服务商的一致接口
- 🚀 延迟创建 - 按需创建产品/价格
- 📦 可树摇 - 只导入需要的内容
- 🔄 轻松迁移 - 切换服务商无需重写代码
你可以做什么:
- ✅ 一次性支付
- ✅ 定期订阅(按月/按年)
- ✅ 基于使用量的计费
- ✅ 客户管理
- ✅ Webhook 处理
- ✅ 订阅生命周期(取消、更新、重新激活)
安装
pnpm add billing无需额外依赖 - 服务商 SDK 已捆绑。
快速开始
步骤 1:选择服务商
最适合:全球业务,自动处理税务/合规
环境变量:
CREEM_API_KEY=creem_test_... # 或 creem_live_... 用于生产环境
CREEM_WEBHOOK_SECRET=your-webhook-secret获取密钥:
- 在 creem.io 注册
- Dashboard → API Keys
- Webhooks:Settings → Webhooks → Add endpoint
自动检测:API URL 根据密钥前缀自动检测:
| 密钥前缀 | API URL |
|---|---|
creem_test_* | https://test-api.creem.io |
creem_live_* | https://api.creem.io |
最适合:美国/欧盟企业,最大灵活性
环境变量:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...获取密钥:
- 在 stripe.com 注册
- Dashboard → Developers → API keys
- Webhooks:Developers → Webhooks → Add endpoint
最适合:个人创始人(为您处理增值税/税务)
环境变量:
LEMONSQUEEZY_API_KEY=your-api-key
LEMONSQUEEZY_STORE_ID=your-store-id
LEMONSQUEEZY_WEBHOOK_SECRET=your-webhook-secret最适合:亚太地区企业
环境变量:
AIRWALLEX_API_KEY=your-api-key
AIRWALLEX_CLIENT_ID=your-client-id
AIRWALLEX_ACCOUNT_ID=your-account-id步骤 2:创建服务商实例
ProductReady 默认使用 Creem。服务商配置在 src/lib/billing/provider.ts:
// src/lib/billing/provider.ts(默认配置)
import { createBillingProvider } from 'billing';
export function getBillingProvider() {
const apiKey = process.env.CREEM_API_KEY;
if (!apiKey) {
throw new Error("CREEM_API_KEY environment variable is required");
}
return createBillingProvider({
provider: 'creem',
apiKey,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
options: {
baseUrl: apiKey.startsWith('creem_test_')
? 'https://test-api.creem.io'
: 'https://api.creem.io',
},
});
}切换服务商,修改配置:
// 示例:切换到 Stripe
export const billingProvider = createBillingProvider({
provider: 'stripe',
apiKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
});步骤 3:创建结账
import { billingProvider } from '~/lib/billing';
// 创建结账会话
const checkout = await billingProvider.createCheckout({
userId: user.id,
productId: 'prod_pro_plan',
priceId: 'price_monthly_29',
successUrl: 'https://productready.com/success',
cancelUrl: 'https://productready.com/pricing',
customerEmail: user.email,
});
// 将用户重定向到结账 URL
return { checkoutUrl: checkout.url };核心概念
产品和价格
产品代表您销售的内容(例如"专业版套餐") 价格定义费用(例如"每月 $29")
// 获取或创建产品(如果不存在则创建)
const product = await billingProvider.getOrCreateProduct({
name: '专业版套餐',
description: '团队的专业功能',
});
// 获取或创建价格(如果不存在则创建)
const price = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents: 2900, // $29.00
currency: 'usd',
interval: 'monthly', // 或 'yearly'
});创建订阅
tRPC 集成
// src/server/routers/billing.ts
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { billingProvider } from '~/lib/billing';
import { z } from 'zod';
export const billingRouter = createTRPCRouter({
createCheckout: protectedProcedure
.input(z.object({
planId: z.enum(['starter', 'pro', 'enterprise']),
interval: z.enum(['monthly', 'yearly']),
}))
.mutation(async ({ ctx, input }) => {
// 将套餐映射到价格
const pricing = {
starter: { monthly: 900, yearly: 9000 },
pro: { monthly: 2900, yearly: 29000 },
enterprise: { monthly: 9900, yearly: 99000 },
};
const amountCents = pricing[input.planId][input.interval];
// 获取或创建产品
const product = await billingProvider.getOrCreateProduct({
name: `${input.planId} 套餐`,
description: `ProductReady ${input.planId} 订阅`,
});
// 获取或创建价格
const price = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents,
currency: 'usd',
interval: input.interval === 'monthly' ? 'monthly' : 'yearly',
});
// 创建结账会话
const checkout = await billingProvider.createCheckout({
userId: ctx.user.id,
productId: product.id,
priceId: price.id,
successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=1`,
cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
customerEmail: ctx.user.email,
});
return { checkoutUrl: checkout.url };
}),
});Webhook 处理
Webhooks 通知您的应用支付事件(订阅创建、支付失败等)
设置 Webhook 端点
// src/app/api/webhooks/billing/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { billingProvider } from '~/lib/billing';
import { db } from '~/db';
import { users } from '~/db/schema';
import { eq } from 'drizzle-orm';
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('stripe-signature') ||
request.headers.get('x-signature') || '';
// 验证 webhook 签名
const event = await billingProvider.verifyWebhook(body, signature);
// 处理不同的事件类型
switch (event.type) {
case 'subscription.created':
case 'subscription.updated': {
// 更新用户订阅状态
await db
.update(users)
.set({
subscriptionId: event.data.id,
subscriptionStatus: event.data.status,
planId: event.data.planId,
})
.where(eq(users.id, event.data.userId));
break;
}
case 'subscription.cancelled': {
// 标记订阅为已取消
await db
.update(users)
.set({
subscriptionStatus: 'cancelled',
subscriptionEndsAt: event.data.endsAt,
})
.where(eq(users.id, event.data.userId));
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook 错误:', error);
return NextResponse.json({ error: 'Webhook 处理失败' }, { status: 400 });
}
}订阅管理
获取用户订阅
export const billingRouter = createTRPCRouter({
getSubscription: protectedProcedure
.query(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
return null;
}
const subscription = await billingProvider.getSubscription(
user.subscriptionId
);
return subscription;
}),
});取消订阅
export const billingRouter = createTRPCRouter({
cancelSubscription: protectedProcedure
.mutation(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
throw new Error('没有活跃订阅');
}
// 取消订阅(保留到计费周期结束)
const subscription = await billingProvider.cancelSubscription({
subscriptionId: user.subscriptionId,
immediately: false, // false = 在周期结束时取消
});
await db
.update(users)
.set({
subscriptionStatus: 'cancelled',
subscriptionEndsAt: subscription.currentPeriodEnd,
})
.where(eq(users.id, ctx.user.id));
return { success: true };
}),
});测试
测试模式
所有服务商都支持测试模式:
# Stripe 测试密钥以 sk_test_ 开头
STRIPE_SECRET_KEY=sk_test_...测试卡号
Stripe:
- 成功:
4242 4242 4242 4242 - 拒绝:
4000 0000 0000 0002
服务商间迁移
无需重写代码即可切换服务商:
// src/lib/billing.ts
// 之前:Stripe
const billingProvider = createBillingProvider({
provider: 'stripe',
apiKey: process.env.STRIPE_SECRET_KEY!,
});
// 之后:LemonSqueezy
const billingProvider = createBillingProvider({
provider: 'lemonsqueezy',
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
storeId: process.env.LEMONSQUEEZY_STORE_ID!,
});所有 tRPC 端点和 webhook 处理程序保持不变!
下一步
- 使用 Creem(默认) - 已配置好,只需添加 API 密钥
- 设置 webhooks - 配置 webhook 端点
/api/billing/webhook - 创建产品 - 产品在首次结账时延迟创建
- 构建 UI - 创建定价页面和结账流程
- 充分测试 - 测试所有支付场景
ProductReady 默认使用 Creem - 自动处理全球税务和合规。如需更多控制,切换到 Stripe 用于美国/欧盟市场。LemonSqueezy 是另一个 MoR 替代方案。