开发技巧

Stripe SaaS 订阅开发:会员权限与客户门户

给一个 SaaS 小工具加上会员订阅:月付和年付两种套餐走 Stripe Checkout 订阅模式,webhook 同步订阅状态控制功能权限,用户通过 Stripe 客户门户自己升级、换卡、看发票和取消订阅,你不用写任何账单管理页面。

BINBHO · 图文教程

给一个 SaaS 小工具加上会员订阅:月付和年付两种套餐走 Stripe Checkout 订阅模式,webhook 同步订阅状态控制功能权限,用户通过 Stripe 客户门户自己升级、换卡、看发票和取消订阅,你不用写任何账单管理页面。

先看清成品

成品是一个 Express 后端:/subscribe 跳转支付,/portal 跳转客户门户,/pro/report 是只有会员能用的接口,/webhook 接收订阅变化。本文代码已实际运行 5 个自动测试全部通过,覆盖未订阅、订阅生效、到期前取消仍可用、取消后失效、扣款失败失效,以及订阅页和门户页的 303 跳转。本文没有真实 Stripe 账号,测试用两种方式代替:一是 SDK 自带的 generateTestHeaderString 生成合法签名的测试事件;二是 Stripe 官方的 API 模拟服务 stripe-mock(Docker 运行),它会校验请求参数并返回固定样例数据。在真实测试账号里点击门户的操作没有在本文环境中进行。

注意:Stripe 账号必须用你自己公司或个人的真实资料,在 Stripe 支持的国家或地区注册,不要借用或租用他人账号收款。销售前确认你所在地区对线上销售、发票和税务的要求。

做完之后你会得到什么?

成果 说明
两个套餐 专业版月付、年付
订阅支付 Checkout 订阅模式
权限控制 根据订阅状态开放会员接口
客户门户 用户自助改套餐、换卡、取消、下载发票
状态同步 webhook 处理创建、更新、删除、扣款失败

需要准备哪些环境?

项目 要求 说明
Stripe 账号 在 Stripe 支持的国家或地区注册 全程使用测试模式,不产生真实扣款
Node.js 20.6 或更高 用到 --env-file 参数
Stripe CLI 最新版 本地接收 webhook、触发测试事件
stripe(Node SDK) 23.x 本文使用 23.0.0

第一步:怎样在 Stripe 后台配置?

1. 创建套餐

在测试模式下进入 商品目录 → 添加产品,名称“专业版”,添加两个周期性价格:

价格 计费周期 查找键(lookup key)
月付 每月 pro_monthly
年付 每年 pro_yearly

金额按你的定价填写。查找键是给代码用的稳定名称,以后改价格不用改代码里的判断。

2. 配置客户门户

进入 设置 → Billing → 客户门户,按需开启:

功能 建议
更新支付方式 开启
查看发票历史 开启
切换套餐 开启,并把月付和年付都加进可切换产品
取消订阅 开启,选择“在计费周期结束时取消”

保存后门户才能使用。

第二步:项目结构是什么样的?

saas-subscription/
├── app.js          # 路由、webhook、权限
├── users.js        # 用户和订阅状态(演示用内存存储)
├── server.js
├── app.test.js
├── package.json
└── .env

1. 让 AI 帮你写初稿(可选)

用 Express 写一个 Stripe 订阅集成:
- POST /subscribe:为用户创建或复用 Stripe Customer,创建 mode=subscription 的 Checkout Session
- POST /portal:创建 billingPortal session 并跳转
- POST /webhook:验签后处理 customer.subscription.created/updated/deleted,把 status、价格 lookup_key、
  订阅项的 current_period_end、cancel_at_period_end 写入用户表;invoice.payment_failed 记录日志
- 只有 active 和 trialing 状态可以访问 /pro/report

第三步:核心代码怎么写?

1. users.js

// 演示用的内存用户表。正式项目请存数据库,字段至少包括 stripeCustomerId 和订阅状态
class Users {
  constructor() {
    this.rows = new Map(); // userId -> { email, stripeCustomerId, plan, status, currentPeriodEnd }
  }
  get(id) {
    return this.rows.get(id);
  }
  upsert(id, patch) {
    this.rows.set(id, { ...(this.rows.get(id) ?? {}), ...patch });
    return this.rows.get(id);
  }
  byCustomer(customerId) {
    for (const [id, u] of this.rows) if (u.stripeCustomerId === customerId) return [id, u];
    return [];
  }
}

// 只有这两种状态算“有效会员”
const ACTIVE = new Set(["active", "trialing"]);
const isMember = (u) => !!u && ACTIVE.has(u.status);

module.exports = { Users, isMember };

2. app.js

const express = require("express");
const { isMember } = require("./users");

function createApp({ stripe, users, webhookSecret, prices, baseUrl }) {
  const app = express();

  // 演示用登录:真实项目从会话里取当前用户
  const currentUser = (req) => String(req.query.user ?? "u1");

  async function ensureCustomer(userId) {
    const u = users.get(userId);
    if (u?.stripeCustomerId) return u.stripeCustomerId;
    const c = await stripe.customers.create({ email: u?.email, metadata: { userId } });
    users.upsert(userId, { stripeCustomerId: c.id });
    return c.id;
  }

  function syncSubscription(sub) {
    const [userId] = users.byCustomer(sub.customer);
    if (!userId) return console.warn(`找不到客户 ${sub.customer} 对应的用户`);
    const item = sub.items?.data?.[0];
    users.upsert(userId, {
      status: sub.status,
      plan: item?.price?.lookup_key ?? item?.price?.id ?? null,
      currentPeriodEnd: item?.current_period_end ?? null,
      cancelAtPeriodEnd: !!sub.cancel_at_period_end,
    });
    console.log(`用户 ${userId} 订阅状态 -> ${sub.status}`);
  }

  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], webhookSecret);
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    switch (event.type) {
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        syncSubscription(event.data.object);
        break;
      case "invoice.payment_failed":
        // 扣款失败:Stripe 会按“智能重试”设置自动重试,这里提醒用户更新银行卡
        console.warn(`扣款失败,客户 ${event.data.object.customer},发邮件提醒更新支付方式`);
        break;
    }
    res.json({ received: true });
  });

  app.use(express.urlencoded({ extended: false }));

  app.post("/subscribe", async (req, res) => {
    const userId = currentUser(req);
    const price = prices[req.body.plan === "yearly" ? "yearly" : "monthly"];
    const session = await stripe.checkout.sessions.create({
      mode: "subscription",
      customer: await ensureCustomer(userId),
      line_items: [{ price, quantity: 1 }],
      success_url: `${baseUrl}/account?user=${userId}`,
      cancel_url: `${baseUrl}/pricing`,
    });
    res.redirect(303, session.url);
  });

  // 客户门户:用户自己改套餐、换卡、下载发票、取消订阅
  app.post("/portal", async (req, res) => {
    const u = users.get(currentUser(req));
    if (!u?.stripeCustomerId) return res.status(400).send("还没有订阅记录");
    const portal = await stripe.billingPortal.sessions.create({
      customer: u.stripeCustomerId,
      return_url: `${baseUrl}/account?user=${currentUser(req)}`,
    });
    res.redirect(303, portal.url);
  });

  // 受保护的会员功能
  app.get("/pro/report", (req, res) => {
    const u = users.get(currentUser(req));
    if (!isMember(u)) return res.status(402).json({ error: "需要订阅专业版" });
    res.json({ report: "这里是专业版报表数据" });
  });

  app.get("/account", (req, res) => res.json(users.get(currentUser(req)) ?? {}));

  return app;
}
module.exports = { createApp };
设计 原因
先创建 Customer 再订阅 用户和 Stripe 客户一一对应,门户和发票都挂在同一客户上
以 webhook 为准更新状态 续费、扣款失败、门户里的操作都不经过你的网站
从订阅项读 current_period_end 较新的 API 版本把计费周期字段放在订阅项上
cancel_at_period_end 用户取消后到期前仍可使用,界面上可以提示“将于某日到期”
past_due 不算会员 这是本文的业务选择,你也可以在重试期间保留权限

3. server.js

const Stripe = require("stripe");
const { createApp } = require("./app");
const { Users } = require("./users");

const users = new Users();
users.upsert("u1", { email: "demo@example.com" });

const app = createApp({
  stripe: new Stripe(process.env.STRIPE_SECRET_KEY),
  users,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  prices: { monthly: process.env.PRICE_MONTHLY, yearly: process.env.PRICE_YEARLY },
  baseUrl: process.env.BASE_URL ?? "http://localhost:4242",
});
app.listen(4242, () => console.log("运行在 http://localhost:4242"));

第四步:怎样在本地跑通?

1. 启动

npm install
cp .env.example .env   # 填入密钥和两个价格 ID
npm start
stripe listen --forward-to localhost:4242/webhook

2. 订阅

curl -i -X POST "localhost:4242/subscribe?user=u1" -d plan=monthly

响应头 Location 是 Stripe 支付页地址,在浏览器打开并用测试卡 4242 4242 4242 4242 付款。

3. 检查结果

检查 期望
stripe listen 收到 customer.subscription.created
curl localhost:4242/account?user=u1 status 为 active,plan 为 pro_monthly
curl localhost:4242/pro/report?user=u1 返回报表数据

4. 打开客户门户

curl -i -X POST "localhost:4242/portal?user=u1"

在浏览器打开 Location 地址,试着切换到年付、再取消订阅,观察 stripe listen 收到的 customer.subscription.updated。

5. 用测试时钟模拟续费(可选)

订阅要等一个月才续费,测试时可以在后台 Billing → 测试时钟 创建时钟和测试客户,把时间往后拨,观察续费和扣款失败的事件。

第五步:自动测试怎么写?

1. app.test.js

const { test, before, after } = require("node:test");
const assert = require("node:assert/strict");
const Stripe = require("stripe");
const { createApp } = require("./app");
const { Users } = require("./users");

const SECRET = "whsec_test_secret";
const useMock = !!process.env.STRIPE_MOCK;
const stripe = new Stripe("sk_test_123", useMock ? { host: "localhost", port: 12111, protocol: "http" } : {});
const users = new Users();
users.upsert("u1", { email: "demo@example.com", stripeCustomerId: "cus_demo" });
let server, base;

before(() => {
  server = createApp({ stripe, users, webhookSecret: SECRET, prices: { monthly: "price_m", yearly: "price_y" }, baseUrl: "http://x" }).listen(0);
  base = `http://localhost:${server.address().port}`;
});
after(() => server.close());

async function send(type, object) {
  const body = JSON.stringify({ id: "evt_" + Math.random(), type, data: { object } });
  const header = stripe.webhooks.generateTestHeaderString({ payload: body, secret: SECRET });
  return fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": header }, body });
}

const sub = (status, extra = {}) => ({
  id: "sub_1", customer: "cus_demo", status, cancel_at_period_end: false,
  items: { data: [{ price: { id: "price_m", lookup_key: "pro_monthly" }, current_period_end: 1790000000 }] }, ...extra,
});

test("未订阅时会员功能返回 402", async () => {
  assert.equal((await fetch(`${base}/pro/report?user=u1`)).status, 402);
});

test("订阅创建后可以使用会员功能", async () => {
  assert.equal((await send("customer.subscription.created", sub("active"))).status, 200);
  assert.equal(users.get("u1").plan, "pro_monthly");
  assert.equal((await fetch(`${base}/pro/report?user=u1`)).status, 200);
});

test("在门户里取消:到期前仍是会员,删除后失去权限", async () => {
  await send("customer.subscription.updated", sub("active", { cancel_at_period_end: true }));
  assert.equal(users.get("u1").cancelAtPeriodEnd, true);
  assert.equal((await fetch(`${base}/pro/report?user=u1`)).status, 200);
  await send("customer.subscription.deleted", sub("canceled"));
  assert.equal((await fetch(`${base}/pro/report?user=u1`)).status, 402);
});

test("扣款失败进入 past_due 后失去权限", async () => {
  await send("customer.subscription.updated", sub("past_due"));
  assert.equal((await fetch(`${base}/pro/report?user=u1`)).status, 402);
});

test("订阅和门户都 303 跳转到 Stripe", { skip: !useMock && "需要 stripe-mock" }, async () => {
  const opt = { method: "POST", redirect: "manual", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "plan=yearly" };
  const a = await fetch(`${base}/subscribe?user=u1`, opt);
  assert.equal(a.status, 303);
  const b = await fetch(`${base}/portal?user=u1`, opt);
  assert.equal(b.status, 303);
  assert.match(b.headers.get("location"), /^https:\/\//);
});

2. 运行

docker run -d --rm -p 12111:12111 stripe/stripe-mock:latest
STRIPE_MOCK=1 npm test

本文实际输出:

ok 1 - 未订阅时会员功能返回 402
ok 2 - 订阅创建后可以使用会员功能
ok 3 - 在门户里取消:到期前仍是会员,删除后失去权限
ok 4 - 扣款失败进入 past_due 后失去权限
ok 5 - 订阅和门户都 303 跳转到 Stripe
# pass 5
# fail 0

出问题时该怎么调?

1. 门户报错 No configuration provided

后台还没保存客户门户设置。按第一步第 2 点配置并保存,测试模式和正式模式要分别配置。

2. 订阅后 account 里没有状态

检查 方法
webhook 是否到达 stripe listen 终端有没有事件
用户能否对上 日志出现“找不到客户”说明 stripeCustomerId 没保存
签名 返回 400 时检查 whsec_

3. 事件顺序错乱

Stripe 不保证事件按顺序到达。本文直接用事件里的订阅对象覆盖状态;要求更严格时,收到事件后用 stripe.subscriptions.retrieve 重新取最新状态再写入。

4. 扣款失败怎么处理

在后台 Billing → 收款设置 里配置智能重试和催款邮件。代码里的 invoice.payment_failed 用来发提醒,引导用户进门户更新银行卡。

常见问题怎么处理?

现象 原因 解决方法
同一用户出现多个 Customer 每次订阅都新建客户 先查 stripeCustomerId 再复用
plan 为空 价格没设查找键 后台编辑价格填 lookup key
取消后立即失去权限 选了“立即取消” 门户设置改为周期结束时取消
用户重复订阅 没检查现有订阅 已是会员时引导去门户而不是 /subscribe
时区显示不对 时间戳是 UTC 秒 前端按用户时区格式化
正式环境收不到事件 端点没订阅相应事件 在 Webhook 端点里勾选订阅相关事件

项目资源

用途 地址
订阅集成指南 https://docs.stripe.com/billing/subscriptions/build-subscriptions
客户门户 https://docs.stripe.com/customer-management
订阅 webhook 事件 https://docs.stripe.com/billing/subscriptions/webhooks
测试时钟 https://docs.stripe.com/billing/testing/test-clocks
订阅状态说明 https://docs.stripe.com/billing/subscriptions/overview
Stripe CLI https://docs.stripe.com/stripe-cli
stripe-mock https://github.com/stripe/stripe-mock
Node SDK https://github.com/stripe/stripe-node

建议先跑自动测试理解状态流转,再在测试模式下真实订阅一次,打开门户把“切换套餐”和“取消”都点一遍。

每做一个操作就看一眼 stripe listen 收到了什么事件,这是理解订阅系统最快的方法。