支付与账单
为你的 ProductReady 应用添加支付处理和订阅计费功能
支付与账单
为你的 ProductReady 应用添加支付处理和订阅计费功能。本指南涵盖:
- ✅ 一次性支付
- ✅ 循环订阅
- ✅ 基于使用量的计费
- ✅ 支付事件 Webhook
- ✅ 客户门户
ProductReady 默认使用 Creem - 一个处理税务和合规的商户记录服务商。查看计费文档了解完整的多服务商设置。本指南展示如何添加 Stripe 作为替代方案。
快速设置
安装 Stripe
pnpm add stripe @stripe/stripe-js
pnpm add -D @types/stripe获取 Stripe 密钥
- 在 stripe.com 创建账户
- 从 Dashboard → Developers → API keys 获取 API 密钥
- 添加到
.env:
# .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # 从 webhooks 设置获取创建 Stripe 客户端
创建 src/lib/stripe.ts:
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('Missing STRIPE_SECRET_KEY');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20.acacia',
typescript: true,
});一次性支付
创建结账会话
添加 tRPC 端点用于创建结账:
// src/server/routers/payments.ts
import { stripe } from '~/lib/stripe';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { z } from 'zod';
export const paymentsRouter = createTRPCRouter({
createCheckout: protectedProcedure
.input(z.object({
priceId: z.string(), // Stripe Price ID
successUrl: z.string().url(),
cancelUrl: z.string().url(),
}))
.mutation(async ({ ctx, input }) => {
const session = await stripe.checkout.sessions.create({
customer_email: ctx.session.user.email,
line_items: [
{
price: input.priceId,
quantity: 1,
},
],
mode: 'payment',
success_url: input.successUrl,
cancel_url: input.cancelUrl,
metadata: {
userId: ctx.session.user.id,
},
});
return { url: session.url };
}),
});在 src/server/routers/index.ts 中注册路由器:
import { paymentsRouter } from './payments';
export const appRouter = createTRPCRouter({
agentTasks: agentTasksRouter,
posts: postsRouter,
payments: paymentsRouter, // ← 添加这个
});在前端使用
'use client';
import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
export function BuyButton({ priceId }: { priceId: string }) {
const createCheckout = trpc.payments.createCheckout.useMutation();
async function handlePurchase() {
const { url } = await createCheckout.mutateAsync({
priceId,
successUrl: `${window.location.origin}/success`,
cancelUrl: `${window.location.origin}/pricing`,
});
if (url) {
window.location.href = url;
}
}
return (
<Button onClick={handlePurchase} disabled={createCheckout.isPending}>
{createCheckout.isPending ? '加载中...' : '立即购买'}
</Button>
);
}订阅计费
创建订阅计划
在 Stripe Dashboard 或通过 API 创建产品:
// 创建产品(一次性设置)
const product = await stripe.products.create({
name: 'Pro 计划',
description: '完整访问所有功能',
});
// 创建价格
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: 'usd',
recurring: {
interval: 'month',
},
});订阅用户
// src/server/routers/subscriptions.ts
export const subscriptionsRouter = createTRPCRouter({
subscribe: protectedProcedure
.input(z.object({
priceId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// 创建或获取 Stripe 客户
let customerId = ctx.session.user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: ctx.session.user.email,
metadata: {
userId: ctx.session.user.id,
},
});
customerId = customer.id;
// 保存到数据库
await ctx.db
.update(users)
.set({ stripeCustomerId: customerId })
.where(eq(users.id, ctx.session.user.id));
}
// 为订阅创建结账会话
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: input.priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return { url: session.url };
}),
// 获取当前订阅
getSubscription: protectedProcedure.query(async ({ ctx }) => {
const customerId = ctx.session.user.stripeCustomerId;
if (!customerId) return null;
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
limit: 1,
});
return subscriptions.data[0] || null;
}),
// 取消订阅
cancel: protectedProcedure.mutation(async ({ ctx }) => {
const customerId = ctx.session.user.stripeCustomerId;
if (!customerId) throw new Error('未找到客户 ID');
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
});
if (subscriptions.data[0]) {
await stripe.subscriptions.cancel(subscriptions.data[0].id);
}
return { success: true };
}),
});Webhooks
在服务器端处理 Stripe 事件:
设置 Webhook 端点
创建 src/app/api/webhooks/stripe/route.ts:
import { headers } from 'next/headers';
import { stripe } from '~/lib/stripe';
import { db } from '~/db';
import { users } from '~/db/schema';
import { eq } from 'drizzle-orm';
import type Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return new Response('无签名', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response(`Webhook 错误: ${err.message}`, { status: 400 });
}
// 处理事件
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// 更新用户订阅状态
if (session.mode === 'subscription') {
await db
.update(users)
.set({
subscriptionStatus: 'active',
subscriptionId: session.subscription as string,
})
.where(eq(users.stripeCustomerId, session.customer as string));
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db
.update(users)
.set({
subscriptionStatus: 'cancelled',
})
.where(eq(users.stripeCustomerId, subscription.customer as string));
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await db
.update(users)
.set({
subscriptionStatus: subscription.status,
})
.where(eq(users.stripeCustomerId, subscription.customer as string));
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
// 发送电子邮件通知或处理支付失败
console.error('支付失败:', invoice.id);
break;
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}在 Stripe 中配置 Webhook
- 前往 Stripe Dashboard → Developers → Webhooks
- 添加端点:
https://yourapp.com/api/webhooks/stripe - 选择事件:
checkout.session.completedcustomer.subscription.deletedcustomer.subscription.updatedinvoice.payment_failed
- 复制 webhook 签名密钥到
.env
客户门户
让用户管理他们的订阅:
export const subscriptionsRouter = createTRPCRouter({
createPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
const customerId = ctx.session.user.stripeCustomerId;
if (!customerId) throw new Error('未找到客户 ID');
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
});
return { url: session.url };
}),
});在组件中使用:
export function ManageBillingButton() {
const createPortal = trpc.subscriptions.createPortalSession.useMutation();
async function handleClick() {
const { url } = await createPortal.mutateAsync();
window.location.href = url;
}
return (
<Button onClick={handleClick}>
管理订阅
</Button>
);
}数据库架构
向用户表添加账单字段:
// src/db/schema/users.ts
export const users = pgTable('users', {
// ... 现有字段 ...
// Stripe 字段
stripeCustomerId: text('stripe_customer_id'),
subscriptionId: text('subscription_id'),
subscriptionStatus: text('subscription_status')
.$type<'active' | 'cancelled' | 'past_due' | 'trialing'>(),
subscriptionPriceId: text('subscription_price_id'),
subscriptionCurrentPeriodEnd: timestamp('subscription_current_period_end'),
});运行迁移:
pnpm db:generate
pnpm db:migrate定价页面示例
// src/app/(marketing)/pricing/page.tsx
'use client';
import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
import { Card } from 'kui/card';
const plans = [
{
name: '入门版',
price: '$9',
priceId: 'price_starter_monthly',
features: ['每月 10 个代理任务', '基础支持', '1 个团队成员'],
},
{
name: '专业版',
price: '$29',
priceId: 'price_pro_monthly',
features: ['每月 100 个代理任务', '优先支持', '5 个团队成员'],
},
{
name: '企业版',
price: '$99',
priceId: 'price_enterprise_monthly',
features: ['无限代理任务', '24/7 支持', '无限团队成员'],
},
];
export default function PricingPage() {
const subscribe = trpc.subscriptions.subscribe.useMutation();
async function handleSubscribe(priceId: string) {
const { url } = await subscribe.mutateAsync({ priceId });
if (url) window.location.href = url;
}
return (
<div className="container mx-auto py-12">
<h1 className="text-4xl font-bold text-center mb-12">定价</h1>
<div className="grid md:grid-cols-3 gap-8">
{plans.map((plan) => (
<Card key={plan.name} className="p-6">
<h3 className="text-2xl font-bold">{plan.name}</h3>
<p className="text-4xl font-bold my-4">{plan.price}<span className="text-sm">/月</span></p>
<ul className="space-y-2 mb-6">
{plan.features.map((feature) => (
<li key={feature}>✓ {feature}</li>
))}
</ul>
<Button
onClick={() => handleSubscribe(plan.priceId)}
className="w-full"
>
订阅
</Button>
</Card>
))}
</div>
</div>
);
}功能限制
根据订阅限制功能:
// src/lib/subscription.ts
export function canUseFeature(user: User, feature: string): boolean {
if (!user.subscriptionStatus || user.subscriptionStatus === 'cancelled') {
return false; // 免费层
}
const limits = {
starter: {
agentTasks: 10,
teamMembers: 1,
},
pro: {
agentTasks: 100,
teamMembers: 5,
},
enterprise: {
agentTasks: Infinity,
teamMembers: Infinity,
},
};
// 根据订阅层级检查
return true; // 实现你的逻辑
}在 tRPC 中使用:
export const agentTasksRouter = createTRPCRouter({
create: protectedProcedure
.input(insertAgentTaskSchema)
.mutation(async ({ ctx, input }) => {
// 检查订阅限制
if (!canUseFeature(ctx.session.user, 'createAgentTask')) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '升级以创建更多代理任务',
});
}
// 创建代理任务...
}),
});测试
使用 Stripe 测试模式:
# 测试卡号
4242 4242 4242 4242 # 成功
4000 0000 0000 0002 # 拒绝
4000 0025 0000 3155 # 3D 安全验证本地测试 webhooks:
# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
# 转发 webhooks 到本地
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# 触发测试事件
stripe trigger checkout.session.completed最佳实践
✅ 应该做
- 使用 webhooks - 不要依赖客户端成功回调
- 存储客户 ID - 在数据库中保存 Stripe 客户 ID
- 处理失败 - 优雅地处理支付被拒绝
- 彻底测试 - 使用测试模式和测试卡
- 保护 webhooks - 验证 webhook 签名
- 显示加载状态 - 结账可能需要几秒钟
❌ 不应该做
- 不要信任客户端 - 在服务器端验证支付
- 不要暴露密钥 - 永远不要发送到前端
- 不要跳过 webhooks - 它们对可靠性至关重要
- 不要忽略错误 - 记录和处理支付失败
- 不要硬编码价格 - 使用 Stripe Price ID
下一步
准备好将你的 SaaS 变现了!🚀