用 Claude Code 实现 Stripe 订阅收费
用 Claude Code 实现 Stripe 订阅:Checkout、客户门户、Webhook、权益表和 SaaS 变现流程。
订阅不是一个付款按钮,而是一套收入系统
Stripe Checkout 可以很快创建订阅,但真正上线的 SaaS、会员内容、模板库或咨询服务,不能只靠一个“立即付款”按钮。你还要处理价格设计、试用期、付款失败、解约、客户自助修改付款方式、Webhook 重试、权限开关和日志追踪。只要其中一个状态没有设计清楚,生产环境里就会变成用户无法访问、已经付款却没开通、取消后仍然能下载等问题。
这里把 Claude Code 当作实现订阅系统的工程助手,而不是只让它生成一段零散代码。更好的做法是先给它官方文档、数据表、状态规则和测试命令。这样 Claude Code 生成的代码更接近真实项目,也更容易审查。
核心模型很简单:Stripe 是账单和订阅状态的事实来源;你的应用数据库只负责判断“这个用户现在能用哪些功能”。例如 active 和 trialing 可以开放功能,past_due 可以给短暂宽限期,unpaid、canceled、paused 则应该撤销付费权限。
flowchart LR
A["定价页"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Stripe 订阅"]
C --> D["签名 Webhook"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["功能权限判断"]
G --> H["SaaS、课程、模板库"]
H --> I["Customer Portal"]
I --> C
先设计价格和权益
不要一开始就对 Claude Code 说“帮我接 Stripe”。先把商业规则写清楚。
| 方案 | 目标用户 | 月费示例 | 年费示例 | 开放权益 |
|---|---|---|---|---|
| Free | 试读文章的访客 | $0 | $0 | 预览文章、样例下载 |
| Pro | 个人开发者、模板购买者 | $19 | $190 | 完整文章、模板下载、课程库 |
| Studio | 团队、企业、长期客户 | $99 | $990 | Pro 权益、团队席位、代码评审、培训资料 |
至少准备 3 个具体用例。第一个是微型 SaaS:付费后开放导出、自动化和分析面板。第二个是信息产品漏斗:免费文章引导到模板库,再引导到月度会员。第三个是培训或咨询业务:用订阅提供团队材料、固定答疑、代码评审额度和发票管理。
entitlement 可以理解为“权益”或“功能许可”,例如 templates:download 表示可以下载模板。dunning 是付款失败后的催收和恢复流程,例如提醒邮件、重试规则、跳转到客户门户更新银行卡。observability 是可观测性,也就是你能查到哪个 Stripe 事件让哪个用户的权限发生了变化。
给 Claude Code 的提示词要带官方文档
Stripe 的 API 和 Billing 功能会更新,涉及 Stripe 的判断要以官方文档为准。可以这样提示 Claude Code:
为 Next.js App Router 实现 Stripe Billing 订阅。
请以这些官方文档为准:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object
要求:
- TypeScript、Next.js App Router、Postgres
- Checkout 使用 mode="subscription"
- Customer Portal 处理付款方式、发票、计划变更和取消
- Webhook 必须验证签名,并用 event_id 去重
- 本地 entitlements 表决定应用功能权限
- active/trialing 开放,past_due 给 3 天宽限,unpaid/canceled/paused 停止
- 给出可以复制粘贴的代码和 stripe listen 测试命令
数据表:账单状态和应用权限分开存
下面的 Postgres 表是最小可用结构。billing_subscriptions 保存 Stripe 订阅状态,entitlements 保存应用真正读取的功能权限,webhook_events 用来处理重复事件和重试。
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
Checkout 和客户门户代码
安装依赖并配置环境变量。示例中的 x-demo-user-id 只是为了方便复制运行,正式项目要替换成自己的认证系统。
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: { priceId: null, features: ["article:preview"] },
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: { metadata: { userId, planKey } },
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
Webhook 同步权限
成功返回页只是用户体验,不是付款证明。权限应该由签名 Webhook 或 Stripe API 的最新订阅状态决定。Stripe 也说明过事件不一定按生成顺序送达,所以处理时最好重新获取订阅。
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
const item = subscription.items.data[0];
const planKey = planKeyFromPrice(item?.price.id);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
}
});
console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
return entry ? (entry[0] as PlanKey) : "free";
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
本地测试
运行监听器,把输出里的 whsec_... 写入 STRIPE_WEBHOOK_SECRET。
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
最接近真实场景的测试仍然是:在测试模式里创建 Product 和 Price,从你的定价页进入 Checkout,完成付款,再打开客户门户修改或取消订阅。
常见坑
不要只靠成功 URL 开通权限。用户可能没有返回,付款可能需要额外验证,Webhook 也可能延迟。成功页可以显示“处理中”,但权限要由 Webhook 或 API 状态决定。
不要只把 userId 放在 Checkout Session 的 metadata。如果后续要从订阅或发票同步权限,也要写入 subscription_data.metadata。
不要把 past_due、unpaid、canceled 混成一种状态。past_due 可以有宽限期;unpaid 和 canceled 通常要停止付费功能。这个规则要和服务条款、邮件文案一致。
不要忘记配置 Stripe Dashboard 里的 Customer Portal。API 只能创建 portal session,能否变更计划、取消、显示哪些产品,需要在 Dashboard 中设置。
变现导线和下一步
Masa 在 ClaudeCodeLab 的内容和模板导线中验证过:最有价值的不是让 Claude Code 多写几行 Stripe 代码,而是先固定状态表。状态表清楚后,提示词、测试、客服回复、分析事件都会变得一致。
继续阅读:Claude Code 支付集成、Claude Code 认证实现、Claude Code 分析实现。如果你想把 Stripe Billing、权益表和 Claude Code 审查流程接到自己的 SaaS、课程或产品漏斗里,可以查看 Claude Code 培训与咨询 或 产品与模板。
实施前请再次核对官方文档:Checkout Sessions API、Customer Portal、subscription webhooks、webhooks、subscription statuses 和 Claude Code setup。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。