Use Cases (更新: 2026/6/2)

用 Claude Code 实现 Stripe 订阅收费

用 Claude Code 实现 Stripe 订阅:Checkout、客户门户、Webhook、权益表和 SaaS 变现流程。

用 Claude Code 实现 Stripe 订阅收费

订阅不是一个付款按钮,而是一套收入系统

Stripe Checkout 可以很快创建订阅,但真正上线的 SaaS、会员内容、模板库或咨询服务,不能只靠一个“立即付款”按钮。你还要处理价格设计、试用期、付款失败、解约、客户自助修改付款方式、Webhook 重试、权限开关和日志追踪。只要其中一个状态没有设计清楚,生产环境里就会变成用户无法访问、已经付款却没开通、取消后仍然能下载等问题。

这里把 Claude Code 当作实现订阅系统的工程助手,而不是只让它生成一段零散代码。更好的做法是先给它官方文档、数据表、状态规则和测试命令。这样 Claude Code 生成的代码更接近真实项目,也更容易审查。

核心模型很简单:Stripe 是账单和订阅状态的事实来源;你的应用数据库只负责判断“这个用户现在能用哪些功能”。例如 activetrialing 可以开放功能,past_due 可以给短暂宽限期,unpaidcanceledpaused 则应该撤销付费权限。

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$990Pro 权益、团队席位、代码评审、培训资料

至少准备 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_dueunpaidcanceled 混成一种状态。past_due 可以有宽限期;unpaidcanceled 通常要停止付费功能。这个规则要和服务条款、邮件文案一致。

不要忘记配置 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 APICustomer Portalsubscription webhookswebhookssubscription statusesClaude Code setup

#Claude Code #Stripe #订阅 #SaaS #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。