计费与套餐
Stripe Checkout、订阅、一次性购买、账单门户和支付生命周期。定义产品目录并接好 Stripe 后即可开始收费,无需重新实现支付系统。
Saavo 模板内置了完整的计费链路,包括产品目录、Stripe Checkout、订阅与一次性购买、Webhook 同步、账单门户、已购产品和支付记录,以及购买后自动授予或收回角色与权益。
开发自己的产品时,通常不需要重新实现支付系统。你需要做的主要是定义自己要卖的套餐和价格,以及配置 Stripe 和 Webhook,并在业务页面和 API 中根据用户已经获得的权益决定哪些功能可以使用。
已有功能
模板初始化后,下面这些计费能力已经可以使用:
| 能力 | 默认入口 | 默认状态 |
|---|---|---|
| 定价展示 | 首页 #pricing | 展示示例订阅套餐 |
| 发起结账 | POST /api/payments/checkout/:productId/:planId | 已实现,需要登录 |
| 订阅 | 示例产品 saavo_starter | 已配置月付和年付示例 |
| 直接买断 | 同一套 Checkout | 已支持,默认示例没有 Lifetime 套餐 |
| 账单门户 | POST /api/payments/stripe/billing | 已购用户可以打开 |
| Stripe Webhook | POST /api/webhooks/stripe | 已实现 |
| 已购产品 | /account | 已登录用户可查看 |
| 支付记录 | /account/payments | 已登录用户可查看 |
| 订阅管理 | /dashboard/payments/subscriptions | 管理员可访问 |
| 单次订单管理 | /dashboard/payments/purchases | 管理员可访问 |
支付成功后,Saavo 会根据套餐中配置的 access 自动授予角色和权益;订阅更新、取消或结束时,对应的授权也会跟随支付生命周期同步调整。
当前模板并没有提供退款相关的接口,这是有意为之。Saavo 模板的定位是同步部分状态,而不是成为管理第三方支付系统的代理。所以类似退款这样的高敏感操作,应该在第三方支付系统中完成。如果退款后你需要取消用户的权益和角色,记得去后台手动取消。
目前 Saavo 模板只会同步第三方的退款结果,该行为主要是为了方便用户查询和统计。默认模板并没有实现类似的功能,如果你的业务需要,需要自行实现相关代码。
开发业务时,不要再创建另一套 Checkout,也不要直接在业务代码中调用 Stripe SDK、自己验签 Webhook,又或者在支付成功页面手动写入权益和角色。
业务代码应该使用 Saavo 已经产生的购买和授权结果。
先体验购买流程
修改产品目录之前,建议先看一遍模板已经提供的定价和购买界面。启动开发服务器后,打开首页,导航中的定价入口会跳转到 #pricing。

默认示例产品 saavo_starter 提供 Hobby、Startup 和 Enterprise 三档套餐,并支持按月和按年切换:
Hobby $19 / 月 或 $190 / 年
Startup $49 / 月 或 $490 / 年 (推荐)
Enterprise $149 / 月 或 $1,490 / 年这些价格来自 config/products.ts 中的 salePrice,只用于页面展示。
真正的扣款金额、币种和计费周期由 Stripe Price 决定,因此正式上线前应该确保页面展示价格与 Stripe 中的实际价格保持一致。
提示
注意:这不是强制行为,但是用户购买产品的时候,肯定希望看到的初始价格就是最终的付款价格。
未登录用户点击购买时,会先要求登录。Saavo 默认不支持匿名购买,因为目前的计费系统需要用户邮箱关联第三方平台的消费者账户。
登录后,账号中心已经提供计费相关功能,进入后可以直接查看已购产品、支付记录和 Stripe 账单门户:
/account 已购产品
/account/payments 支付记录和 Stripe 账单门户
管理员还可以通过下面的页面查看订阅和一次性购买记录:
/dashboard/payments/subscriptions 订阅
/dashboard/payments/purchases 一次性购买
提示
默认产品目录中的 Price ID 仍然是 *_replace_me 占位符。在配置 Stripe 密钥并替换为真实 Price ID 之前,定价页可以正常展示,但 Checkout 无法真正完成。这属于模板的默认状态,并不是计费模块发生了错误。
完成 Stripe 配置后,一次正常的购买流程大致是:
用户选择套餐
↓
完成登录
↓
进入 Stripe Checkout
↓
完成付款
↓
Saavo 同步支付结果
↓
根据套餐 access 授予角色和权益
↓
用户开始使用付费功能用户后续取消订阅、更换付款方式或查看发票等操作,可以继续通过 Stripe Billing Portal 完成,无需自己再实现一套账单管理后台。
配置产品和计费
默认产品目录主要用于展示模板能力。
正式开发自己的产品时,需要根据实际业务决定你卖什么、怎么收费,以及付款后用户能够获得什么。
定义销售内容
产品目录定义在:
config/products.ts模板预置了一个订阅产品 saavo_starter,其中月付和年付分别定义为独立的套餐。每个套餐通过 priceId 对应一个 Stripe Price。
常用字段包括:
| 字段 | 含义 |
|---|---|
type | 'recurring' 表示订阅,'one-time' 表示一次性购买 |
interval / intervalCount | 订阅周期,例如每月或每年 |
priceId | Stripe 中对应的 Price ID |
salePrice | 页面展示价格 |
title / description / features | 定价卡片展示内容 |
default | 是否作为默认套餐 |
recommended | 是否显示推荐标记 |
allowRepurchase | 已购买用户是否允许再次购买 |
access.roles | 购买后授予的角色 |
access.entitlements | 购买后授予的权益 |
配置中的 plans 是平铺的,每个套餐直接声明计费周期和 priceId:
saavo_starter
└── plans
├── license Hobby 月付 → 一个 Stripe Price
├── hobby_yearly Hobby 年付 → 一个 Stripe Price
├── startup_monthly Startup 月付 → 一个 Stripe Price
├── startup_yearly Startup 年付 → 一个 Stripe Price
├── enterprise_monthly Enterprise 月付 → 一个 Stripe Price
└── enterprise_yearly Enterprise 年付 → 一个 Stripe PriceHobby、Startup 和 Enterprise 是页面展示的商业档位,不是配置中再包一层月付、年付的对象。增加计费周期时,应在 plans 中增加对应套餐。
首页定价区默认使用 saavo_starter 作为主产品。如果只是基于模板创建自己的 SaaS,比较简单的做法是保留这个 Product ID,只替换产品名称、套餐、价格和权益。
所有类似:
price_hobby_monthly_replace_me的占位符,在正式测试支付之前都必须替换成 Stripe 中真实存在的 Price ID。
示例中的套餐名称、描述和 Feature 也应该全部替换成自己的产品内容。
完整的新增套餐流程见:
选择订阅或一次性购买
默认示例全部使用订阅。
订阅适合持续提供服务的产品,例如:
$19 / 月
$190 / 年如果你的产品提供买断、License 或 Lifetime,也可以创建一次性套餐:
lifetime: {
type: 'one-time',
priceId: 'price_your_test_or_live_id',
allowRepurchase: false,
salePrice: '$199',
title: {
value: 'Lifetime',
},
access: {
roles: ['premium'],
entitlements: [
{
target: 'starter_download',
config: {
kind: 'boolean',
priority: 100,
description: 'Lifetime access',
},
},
],
},
},一次性购买和订阅使用同一套 Checkout,主要区别由套餐的 type 决定。
需要注意,默认首页定价组件主要围绕月付和年付订阅设计,不会自动展示 one-time 套餐。
如果需要 Lifetime 套餐,可以在自己的业务页面中增加独立的购买入口。
一次性购买记录会显示在:
/dashboard/payments/purchases完整示例见:
订阅套餐还可以设置试用时间,例如:
trialPeriod: '14 days',试用结束后的默认行为由 config/payment.ts 控制:
subscriptionTrialEndBehavior: 'cancel',确认购买后提供的内容
计费系统负责解决:
用户是否已经完成购买?
而套餐中的 access 决定:
用户购买后能够获得什么?
例如一个套餐可以配置:
access: {
roles: ['premium'],
entitlements: [
{
target: 'starter_download',
config: {
kind: 'boolean',
priority: 100,
},
},
],
},购买成功以后,Saavo 会根据这里的配置自动授予对应的 Role 和 Entitlement。
整个关系可以理解为:
用户购买套餐
↓
套餐 access
├── roles
└── entitlements
↓
能力
↓
解锁业务功能对于实际的付费功能,通常应该通过 Entitlement 或 Capability 判断用户是否拥有访问权限,而不是只判断用户有没有一个叫做 premium 的角色。
根据实际业务,你可以有多层的判断逻辑,它们之间都是相互独立的。例如你可以根据用户的角色来判断用户可以访问的页面和功能;你可以根据用户的权益来判断用户是否拥有某些操作权限;你还可以根据用户当前的能力来决定是否允许用户执行某些操作。
它们看似有一定的关联性,但是你完全可以根据实际业务需求,选择其中的一种或几种判断逻辑。大多数的情况下,你根本不需要判断 Capability,只需要判断用户的角色和权益即可。如果多个角色或权益提供同一种业务能力,也可以通过 Capability 统一检查。
如果你的套餐需要限制使用次数、额度或者 Credits,则需要配置 Numeric Entitlement,并在业务执行成功后消耗对应额度。
详细说明见:
设置付款后的跳转页面
模板默认在 Stripe Checkout 成功后返回 /account,账单门户关闭后返回首页:
stripe: {
checkout: {
successRedirectPath: '/account',
fallbackRedirectPath: '/',
},
billingPortal: {
returnRedirectPath: '/',
fallbackRedirectPath: '/',
},
}对于真实 SaaS 产品,付款成功以后通常应该直接进入应用。
例如产品主界面位于 /app:
stripe: {
checkout: {
successRedirectPath: '/app',
fallbackRedirectPath: '/',
},
billingPortal: {
returnRedirectPath: '/account/payments',
fallbackRedirectPath: '/',
},
}调整后:
Checkout 成功 → /app
Checkout 无法继续 → /
离开账单门户 → /account/payments建议在初始化产品时尽早修改这些路径,避免用户付款完成后仍然返回模板默认页面。
调整 Stripe Checkout
Checkout 的部分行为可以在 config/payment.ts 中调整:
stripe: {
enableAutomaticTax: true,
enablePromoCodes: true,
enableTaxIdCollection: false,
enableTermsOfServiceConsent: false,
enableInvoiceCreation: false,
}如果希望 Stripe 自动计算适用税费,可以保持:
enableAutomaticTax: true如果希望用户在 Checkout 中输入优惠码:
enablePromoCodes: true如果是面向企业的产品,需要收集 Tax ID:
enableTaxIdCollection: true如果需要用户在付款前确认服务条款:
enableTermsOfServiceConsent: true对于一次性购买,如果希望 Stripe 同时创建 Invoice,可以开启:
enableInvoiceCreation: true订阅本身已经通过 Invoice 完成计费,因此通常不需要依赖这个配置。
套餐还可以预设 Stripe Coupon。配置套餐级 Coupon 后,Checkout 会自动使用对应优惠;没有预设 Coupon 时,可以使用 enablePromoCodes 允许用户自行输入优惠码。需要通过专属链接为不同渠道展示并应用不同 Coupon 时,见动态优惠码。
准备 Stripe 服务
计费逻辑已经内置在模板中,但真正完成扣款仍然依赖 Stripe 账户、密钥、Product、Price 和 Webhook。
建议先使用 Stripe Test Mode 完整跑通流程,再切换到 Live Mode。
配置 Stripe 密钥
本地开发至少需要配置:
STRIPE_CONNECTION_ID=stripe-test
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...其中:
STRIPE_SECRET_KEY 是 Stripe Secret Key。开发和测试环境使用 Test Mode 密钥,生产环境必须切换为 Live Mode 密钥。
STRIPE_WEBHOOK_SECRET 是 Webhook Endpoint 的签名密钥,必须与当前实际接收事件的 Endpoint 对应。
STRIPE_CONNECTION_ID 用于区分不同 Stripe 连接和环境。
例如:
本地 / 测试环境 → stripe-test
生产环境 → stripe-live生产环境上线后,不建议随意修改 STRIPE_CONNECTION_ID,否则相同的 Stripe 对象可能会被识别为另一组连接数据。
修改 .env 后需要重新启动开发服务器。
在 Stripe 中创建 Product 和 Price
进入 Stripe Dashboard,并切换到 Test Mode。
按照 config/products.ts 中定义的套餐创建对应 Product 和 Price。
然后将每一个:
priceId: 'price_xxx_replace_me'替换为真实的 Stripe Price ID。
它们之间的关系是:
config/products.ts
plan.priceId
↓
Stripe Price ID
↓
Stripe Checkout
↓
Webhook
↓
匹配本地套餐
↓
授予 access因此,priceId 必须精确对应当前套餐使用的 Stripe Price。
priceId、Stripe 账户以及 Test / Live Mode 也必须保持一致。
例如,本地使用:
STRIPE_SECRET_KEY=sk_test_...那么 priceId 也必须属于同一个 Stripe 账户的 Test Mode。
页面中的 salePrice 不会影响 Stripe 实际扣款。
因此上线前应该检查:
页面 salePrice
≈
Stripe Price避免用户在定价页看到 $19,进入 Checkout 后却显示另一个价格。
配置 Webhook 回调
Saavo 的 Stripe Webhook Endpoint 为:
POST /api/webhooks/stripe生产环境中,对应地址例如:
https://your-domain.com/api/webhooks/stripe需要在 Stripe Dashboard 中创建 Webhook Endpoint,并订阅 Saavo 使用的支付生命周期事件。
创建完成后,把该 Endpoint 对应的 Signing Secret 写入:
STRIPE_WEBHOOK_SECRET=whsec_...生产环境需要使用正式 HTTPS 域名,并使用 Live Mode 创建的 Webhook Endpoint 和 Signing Secret。
完整生产回调地址见:
在本地测试支付
Stripe 无法直接访问你的本地开发服务器,因此本地测试 Webhook 时需要把事件转发到本机。
推荐使用 Stripe CLI:
stripe listen --forward-to localhost:5173/api/webhooks/stripeCLI 启动后会输出一个临时 Webhook Secret:
whsec_...将它写入本地:
STRIPE_WEBHOOK_SECRET=whsec_...然后重启开发服务器。
如果本地开发服务器不是 5173 端口,则把命令中的端口改成当前实际端口。
完成配置后,可以使用 Stripe Test Mode 提供的测试支付方式完成一次完整购买。
本地测试时应该验证的不只是 Checkout 是否付款成功,还应该继续确认:
Checkout 成功
↓
Webhook 到达
↓
本地购买记录产生
↓
access 被授予
↓
付费功能可以访问如果 Checkout 已经成功,但 Webhook 没有正常到达,本地通常不会产生完整的购买和授权结果。
在业务中使用支付结果
完成产品目录和 Stripe 配置以后,计费系统就可以开始工作。
接下来业务代码最重要的事情不是继续操作 Stripe,而是使用计费系统已经产生的授权结果。
基本原则是:
不要在业务代码中直接调用 Stripe SDK,也不要自己查询支付表或者在 Checkout 成功页面手动授予权益。
Saavo 已经通过 Checkout、Webhook 和支付生命周期处理购买、同步、授权和取消授权。
业务代码只需要判断:
当前用户是否拥有执行这个功能所需要的权限?
保护付费功能
例如某个下载功能只允许购买了对应套餐的用户使用,可以直接检查 Entitlement:
const allowed = await authz.hasEntitlement(
c,
'starter_download',
);
if (!allowed) {
return c.redirect(
getFullPath('/#pricing', locale),
);
}也可以检查 Capability:
const allowed = await authz.hasEntitlementCapability(
c,
'starter.download',
);购买后的授权和订阅结束后的收回都由支付生命周期负责,因此不需要在 Checkout 成功页手动插入权限数据。
对于 API,同样应该在服务端完成检查:
if (!await authz.hasEntitlement(c, 'starter.download')) {
return c.json(
{
success: false,
code: gResultCode.authPermissionDenied,
error: 'api.auth.permissionDenied',
},
403,
);
}一个常见的请求流程是:
请求
↓
authenticatedGuard()
↓
authContext
↓
authz.hasEntitlement()
↓
业务逻辑认证负责判断:
当前用户是谁?
计费和权益系统进一步判断:
当前用户什么角色,开通了什么权益?可以做什么操作?
完整示例见:
判断用户是否已购买
有时业务并不是要判断用户当前是否拥有某个能力,而只是想知道:
这个用户是否已经购买过当前套餐?
可以使用:
await authz.hasPurchasedProduct(
c,
productId,
planId,
);它与权益检查的用途不同。
对于订阅套餐,它主要用于判断当前是否存在对应套餐的有效购买关系。
对于一次性套餐,则用于判断用户是否已经完成过对应购买。
因此:
是否允许使用付费功能
→ 检查 Entitlement / Capability
是否还允许再次购买同一个套餐
→ 检查 hasPurchasedProduct()Checkout 在 allowRepurchase: false 时也会使用类似逻辑避免重复购买。
在业务页面发起购买
首页定价区已打通购买流程。如需在业务页面添加购买按钮,请继续使用统一的 Checkout API:
POST /api/payments/checkout/:productId/:planId前端只需要传:
productId
planId例如:
async function buy(productId: string, planId: string) {
const result = await postService<{ type: 'redirect'; url: string }>(
`/api/payments/checkout/${productId}/${planId}`,
);
if (!result.success) {
return;
}
if (result.data.type === 'redirect') {
replace(result.data.url);
}
}如果用户没有登录,可以先打开登录弹窗:
const { isLoggedIn } = useCurrentUser();
if (!isLoggedIn) {
setLoginModalOpen(true);
return;
}服务端会根据 productId 和 planId 找到对应套餐,并根据套餐类型创建 Subscription Checkout 或一次性 Payment Checkout。
需要注意,前端的登录状态和按钮状态只能用于改善用户体验。真正涉及付费功能访问权限时,仍然必须在服务端检查 Entitlement 或 Capability。
提示
尽量不要把 Stripe Price ID 作为业务参数直接暴露给前端。
在用户中心管理账单
Saavo 已经在 /account 中提供账号级别的计费信息,不需要重新实现一套 Billing Settings。
适合继续放在账号中心的内容包括:
- 已购产品
- 支付记录
- Stripe Billing Portal
- 发票和付款方式管理入口
用户取消订阅、更换信用卡或者查看 Stripe 发票,可以通过 Billing Portal 完成。
属于具体 SaaS 产品的计费信息,则应该由业务页面自己实现。
例如 AI 产品可能还需要:
当前剩余额度
本月使用量
升级套餐
Credits 使用记录
工作区计费状态这些内容更适合出现在产品自己的 Dashboard 中,当然你也可以直接复用并修改用户中心的产品购买页面,以满足你的业务需求。
一个简单的判断方式是:
与“这个账号买过什么、怎么付款”有关的内容放在
/account;与“当前产品怎么使用这些购买结果”有关的内容放在业务页面。
上线检查
收费系统是正式上线前必须完整验证的基础功能。
建议至少检查下面这些流程:
-
config/products.ts中已经没有*_replace_mePrice ID - 定价页中的套餐名称、价格、描述和 Feature 已替换成自己的产品
- 页面展示价格与 Stripe Price 保持一致
- 每个套餐的
access.roles/access.entitlements与实际付费能力对应 - 未登录用户点击购买时会先要求登录
- 使用 Stripe Test Mode 可以完成订阅购买
- 如果存在一次性套餐,也已经完成一次完整测试
- Checkout 成功后 Webhook 可以正常到达
-
/account可以看到已购产品 -
/account/payments可以看到支付记录并打开 Billing Portal - 购买后对应 Entitlement / Capability 已经生效
- 付费功能可以正常访问
- 取消订阅后,在订阅真正结束时对应权益会被收回
- 管理员可以在 Dashboard 查看订阅和一次性购买记录
- Checkout 成功路径和 Billing Portal 返回路径已经改成自己的产品地址
- 生产环境使用 Live Mode Stripe 密钥
- 生产环境使用独立的
STRIPE_CONNECTION_ID - 生产 Webhook 已切换到正式 HTTPS 域名
- Test 和 Live 的 Secret Key、Webhook Secret、Price ID 没有混用
建议使用一个全新注册的普通用户,从注册开始完整执行一次:
注册
↓
登录
↓
购买
↓
Webhook
↓
获得权益
↓
使用付费功能
↓
进入 Billing Portal
↓
取消订阅
↓
订阅结束
↓
权益收回这样比只验证“Stripe 页面显示付款成功”更可靠。
常见问题
接下来
根据接下来准备开发的功能,可以继续阅读:
- 想看真实产品如何设计收费方式 → WebpageToPDF 实战教程
- 想增加一个 Stripe 套餐 → 添加一个 Stripe 套餐
- 想增加 Lifetime 或一次性购买 → 一次性套餐
- 想限制付费功能 → 付费才开放的功能
- 想实现次数限制或 Credits → 设计套餐与权益
- 想了解角色和权益 → 权限与权益
- 想配置联盟返佣 → 联盟
- 想查支付数据表和字段 → 数据库
对于大多数产品,完成本章后就不需要继续修改支付系统本身。
接下来可以直接基于当前用户的购买和授权结果,把真正需要收费的业务能力接入产品。