给一个 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 端点里勾选订阅相关事件 |
项目资源
建议先跑自动测试理解状态流转,再在测试模式下真实订阅一次,打开门户把“切换套餐”和“取消”都点一遍。
每做一个操作就看一眼 stripe listen 收到了什么事件,这是理解订阅系统最快的方法。