计费与套餐

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 WebhookPOST /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订阅周期,例如每月或每年
priceIdStripe 中对应的 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 Price

Hobby、Startup 和 Enterprise 是页面展示的商业档位,不是配置中再包一层月付、年付的对象。增加计费周期时,应在 plans 中增加对应套餐。

首页定价区默认使用 saavo_starter 作为主产品。如果只是基于模板创建自己的 SaaS,比较简单的做法是保留这个 Product ID,只替换产品名称、套餐、价格和权益。

所有类似:

price_hobby_monthly_replace_me

的占位符,在正式测试支付之前都必须替换成 Stripe 中真实存在的 Price ID。

示例中的套餐名称、描述和 Feature 也应该全部替换成自己的产品内容。

完整的新增套餐流程见:

添加一个 Stripe 套餐

选择订阅或一次性购买

默认示例全部使用订阅。

订阅适合持续提供服务的产品,例如:

$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/stripe

CLI 启动后会输出一个临时 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_me Price 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 页面显示付款成功”更可靠。

常见问题

接下来

根据接下来准备开发的功能,可以继续阅读:

对于大多数产品,完成本章后就不需要继续修改支付系统本身。

接下来可以直接基于当前用户的购买和授权结果,把真正需要收费的业务能力接入产品。