- 后端
- 前端
【免费下载链接】react-starter-kit
Modern React starter kit with Bun, TypeScript, Tailwind CSS, tRPC, Stripe, and Cloudflare Workers. Production-ready monorepo for building fast web apps.
本篇技术指南围绕 React Starter Kit 的订阅计费模块展开:它通过@better-auth/stripe插件,把客户生命周期、订阅状态与 Webhook 处理并入已有的认证体系,与 session、organization 共用同一套数据库。读完本文,你将掌握该项目的计费架构(tRPC 读 + Better Auth client 写)、组织级/个人级计费的 reference 语义、Plan 配置的单一事实源、环境变量与 Webhook 的完整落地方法,以及对应的测试覆盖策略。
计费概览:Billing 与 Auth 的强耦合设计
React Starter Kit 的计费由@better-auth/stripe插件驱动,其核心设计取向是计费与认证强耦合——客户生命周期、订阅状态和 Webhook 处理由同一套管理系统(session、organization)负责,而不是独立搭建一套计费服务。
该模块明确排除了以下能力(见 docs/specs/billing.md):用量计费(usage-based billing)、计量定价(metered pricing)、一次性支付、开票(invoicing)、Stripe Elements/嵌入式 Checkout、税费计算与多币种。这些都可以在未来增量添加,不影响当前架构的扩展性。
计费是可选的:不配置STRIPE_*环境变量时应用一切正常——插件 mutation 端点返回 404、订阅查询报告 free 计划并带enabled: false、设置页隐藏计费控件。但必须四个必需变量全部配置或全部不配置:部分配置会在认证初始化时直接抛错,而不是静默禁用计费,避免"看起来像应用 bug"的排查困境。
关键决策理由
规范文档给出了三项核心选型决策,均可在源码中得到印证:
Better Auth 插件而非裸 Stripe SDK:项目本就用 Better Auth 处理认证、组织和 session(见 apps/api/lib/auth.ts 中
createAuth的插件列表:anonymous()、organization()、passkey()、emailOTP())。插件顺带承担了客户同步、订阅生命周期、Webhook 验签与组织级计费,省去了大量胶水代码。Hosted Checkout 而非嵌入式 Elements:Stripe Checkout 开箱即满足 PCI 合规、无需引入
@stripe/stripe-js客户端依赖,自动处理支付方式选择、3D Secure 和收据。项目保留未来升级嵌入式 Elements 的路径,但初期不需要。createCustomerOnSignUp: true:在注册时即预创建 Stripe 客户记录,简化升级流程并支持 Stripe 侧数据分析。代价是未升级用户会留下无用的客户记录——一个明确的取舍。
架构与数据流
规范文档给出了完整的架构图,订阅的读与写走了两条不同的链路:
┌─────────────┐ POST /api/auth/subscription/upgrade ┌───────────────┐ │ Browser │ ──────────────────────────────────────────→ │ API Worker │ │ (app) │ │ (Hono) │ │ │ ←── 302 redirect │ │ │ │──→ Stripe Checkout (hosted) │ Better Auth │ │ │ │ + stripe() │ │ │ POST /api/auth/stripe/webhook │ plugin │ │ │ Stripe ────────→│ webhook ──→ │ │ │ │ update DB │ │ │ GET /api/trpc/billing.subscription │ │ │ │ ──────────────────────────────────────────→ │ tRPC router │ └─────────────┘ ←── subscription data (TanStack Query) └───────────────┘完整的数据流为:
- 用户点击Upgrade——Better Auth 客户端调用
auth.subscription.upgrade() - 插件创建 Stripe Checkout session,302 重定向浏览器到 Stripe
- 用户完成支付——Stripe 向
/api/auth/stripe/webhook发送 Webhook - 插件验签并更新
subscription表 - 客户端通过 tRPC + TanStack Query 重新拉取计费状态
为什么读走 tRPC、写走 Better Auth client:订阅查询需要 TanStack Query 的缓存、批处理和 stale-while-revalidate 能力;而应用自己拥有的两个 mutation(upgrade 和 portal)走 auth client,是因为插件内部处理了 Stripe API 调用、session 校验和组织授权。取消订阅发生在 Stripe 托管的 Customer Portal 内,并不经过项目自定义的 procedure。
Billing Reference:组织级与个人级计费的统一语义
计费读取使用session.activeOrganizationId(存在时),否则回退到user.id。Stripe mutation 侧则将活跃组织显式作为referenceId并以customerType: "organization"传入;没有活跃组织时插件默认按当前用户计费。插件对每个 reference ID 只允许一个活跃订阅。
| 上下文 | referenceId | 谁可以管理 |
|---|---|---|
| 存在活跃组织 | activeOrganizationId | 仅组织 owner/admin |
| 无活跃组织 | user.id | 用户本人 |
这套语义在客户端 apps/app/lib/queries/billing.ts 的billingReference()中实现:
function billingReference(activeOrgId?: string | null) { return activeOrgId ? ({ referenceId: activeOrgId, customerType: "organization" } as const) : {}; }授权被"两个不同的所有者"各执行一次:
- Stripe mutation 侧:插件配置中的
authorizeReference回调对每个显式组织引用做 owner/admin 成员校验(见 apps/api/lib/auth.ts 第 147-159 行):authorizeReference: async ({ user, referenceId }) => { if (referenceId === user.id) return true; // 个人计费始终自管 const [row] = await db .select({ role: Db.member.role }) .from(Db.member) .where( and( eq(Db.member.organizationId, referenceId), eq(Db.member.userId, user.id), ), ); return canManageOrgBilling(row?.role); }, - tRPC 读取侧:
billing.subscription是项目自己拥有的 procedure,authorizeReference不会覆盖它,因此它自行校验成员关系后再读订阅,否则会抛出FORBIDDEN。这一道校验是必要的——session 的生命周期长于成员关系,没有它,一个已被移出组织的 session 会继续读到旧组织的套餐信息。
两次授权共用同一个谓词canManageOrgBilling(role)(见 apps/api/lib/plans.ts 第 18-20 行:role === "owner" || role === "admin"),从根源上保证两端不会漂移。
同一成员查询顺带返回调用者角色,响应中携带canManage字段:每个成员都能看到套餐信息,但只有 owner/admin 能看到升级和门户按钮——因为这些人恰好是authorizeReference会放行的人。计费查询 key 包含activeOrgId,因此切换组织时 TanStack Query 会自动拉取新的计费数据。
数据库 Schema:订阅表由插件全权管理
插件依赖user和organization表上的stripeCustomerId字段,以及一张subscription表。subscription表由插件管理,无需手动插入或更新。
- db/schema/user.ts 第 41 行:
stripeCustomerId: text() - db/schema/organization.ts 第 20 行:
stripeCustomerId: text() - db/schema/subscription.ts:完整的订阅状态表
subscription表的核心字段(Drizzle ORM 定义,见 db/schema/subscription.ts):
| 字段 | 类型/默认值 | 说明 |
|---|---|---|
id | text主键 | 通过generateAuthId("subscription")生成前缀 ID |
plan | text notNull | 套餐名(starter / pro 等) |
referenceId | text notNull | 多态引用:指向user.id或organization.id,取决于个人/组织计费 |
stripeCustomerId | text | Stripe 客户 ID |
stripeSubscriptionId | text unique | Stripe 订阅 ID |
status | text default "incomplete" | 订阅状态 |
periodStart/periodEnd | timestamp | 计费周期 |
trialStart/trialEnd | timestamp | 试用期 |
cancelAtPeriodEnd | boolean default false | 周期末是否取消 |
cancelAt/canceledAt/endedAt | timestamp | 取消相关时间戳 |
seats | integer | 席位 |
billingInterval | text | 计费周期 |
stripeScheduleId | text | 计划变更延后到周期末生效时用的 Stripe schedule ID,应用或取消后清除 |
表上建有subscription_reference_id_idx与subscription_stripe_customer_id_idx两个索引,支撑按 reference 和客户维度的查询。
Schema 必须与插件预期匹配。认证配置变更后,需要同步更新db/schema/下的文件,并运行bun db:generate生成迁移。
Plan 配置:单一事实源与逃生舱
计划限额定义在 apps/api/lib/plans.ts(单一事实源),同时被认证插件配置和 tRPC router 引用:
export const planLimits = { free: { members: 1 }, starter: { members: 5 }, pro: { members: 50 }, } as const;Price ID 来自环境变量(STRIPE_*_PRICE_ID)。"配置即代码"(config-as-code)是这里最简单且正确的方案——套餐很少变动,这种方式让套餐可测试、可纳入版本控制。
插件侧的套餐注册位于 apps/api/lib/auth.ts 的stripePlugin()中:
stripe({ stripeClient: new Stripe(secretKey, { appInfo: { name: "React Starter Kit" }, }), stripeWebhookSecret: webhookSecret, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: starterPriceId, limits: planLimits.starter, }, { name: "pro", priceId: proPriceId, annualDiscountPriceId: env.STRIPE_PRO_ANNUAL_PRICE_ID, limits: planLimits.pro, freeTrial: { days: 14 }, }, ], authorizeReference: /* 见上文 */, }, organization: { enabled: true }, })由此可以看到三个重要细节:
- Pro 计划带 14 天免费试用(
freeTrial: { days: 14 })与年付折扣(annualDiscountPriceId,指向可选的STRIPE_PRO_ANNUAL_PRICE_ID)。配置了年付 Price ID 后,Stripe Checkout 会自动同时展示月付与年付选项。 - free 层没有对应的 Stripe 套餐——没有活跃订阅的用户即视为 free。
limits对象会随订阅写入 Stripe 元数据并由插件返回。 - 逃生舱(escape hatch):插件接受
plans: () => StripePlan[]动态函数形式。规范文档明确建议仅在出现真实的运行时计划管理需求时(例如后台管理系统的套餐 CRUD)才切换到这个形式。
限额执行:limits对象由billing.subscriptiontRPC 查询返回,但限额必须在应用逻辑中执行(tRPC 中间件做服务端校验、UI 守卫做客户端拦截),插件本身不强制限额。例如"添加成员"这类操作应在业务代码里对照limits.members做检查。
在 Stripe Dashboard 中创建套餐
每个付费套餐都需要在 Stripe Dashboard 中创建 Product 与 Price:
- 创建一个产品(例如 "Starter Plan")
- 添加一个周期性价格(例如 $9/月)
- 将 Price ID(
price_...)复制到对应的环境变量
| 套餐 | 环境变量 | 产品示例 |
|---|---|---|
| Starter | STRIPE_STARTER_PRICE_ID | "Starter Plan" – $9/月 |
| Pro(月付) | STRIPE_PRO_PRICE_ID | "Pro Plan" – $29/月 |
| Pro(年付) | STRIPE_PRO_ANNUAL_PRICE_ID | "Pro Plan" – $290/年 |
开发期间请使用 Stripetest mode——测试与线上模式的 Price ID 是不同的。
环境变量配置
规范文档列出了五个环境变量及其前缀约定:
| 变量 | 前缀 | 说明 |
|---|---|---|
STRIPE_SECRET_KEY | sk_ | Stripe 密钥 |
STRIPE_WEBHOOK_SECRET | whsec_ | Webhook 签名密钥 |
STRIPE_STARTER_PRICE_ID | price_ | Starter 套餐价格 ID |
STRIPE_PRO_PRICE_ID | price_ | Pro 套餐价格 ID |
STRIPE_PRO_ANNUAL_PRICE_ID | price_ | Pro 年付价格 ID(可选) |
部署位置:本地开发写入.env.local;staging/production 使用 Cloudflare secrets(对应 docs/getting-started/environment-variables.md)。
部分配置即报错的实现见 apps/api/lib/auth.ts 第 100-121 行:四个变量全缺返回[](禁用插件);部分缺失时抛出带缺失变量名的错误,明确提示"设置全部四个以启用计费,或全部不设置以禁用,STRIPE_PRO_ANNUAL_PRICE_ID两种情况下都保持可选"。
Webhook:插件自动注册端点
插件自动注册POST /api/auth/stripe/webhook端点(无需手动挂载路由),处理以下事件:
checkout.session.completed—— 激活订阅customer.subscription.created—— 记录新订阅customer.subscription.updated—— 同步状态与取消排期customer.subscription.deleted—— 标记订阅已取消
Stripe Dashboard 配置
Endpoint URL: https://<domain>/api/auth/stripe/webhook Events: - checkout.session.completed - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted本地开发
stripe listen --forward-to localhost:5173/api/auth/stripe/webhook # 把 whsec_... 签名密钥复制到 .env.localRaw Body 要求
Stripe Webhook 验签需要原始请求体。插件通过request.text()自行处理,无需任何额外的 Hono 中间件。
客户端与 UI:设置页的 BillingCard
客户端侧由三部分组成:
stripeClient:在 apps/app/lib/auth.ts 中注册stripeClient({ subscription: true }),为 auth client 挂载auth.subscription.upgrade()与auth.subscription.billingPortal()。- 查询与变更 hooks:apps/app/lib/queries/billing.ts 定义
billingQueryKey(["billing", "subscription"],用于批量失效)、billingQueryOptions(activeOrgId)、useBillingQuery、useUpgradeSubscription和useBillingPortal。 - BillingCard:apps/app/routes/(app)/settings.tsx/settings.tsx) 中的
BillingCard组件渲染所有计费状态。
值得注意的客户端细节:
- mutation 的错误处理:Better Auth 以
{ error }形式返回而非抛异常,两个 mutation hook 都显式if (error) throw error,否则按钮会在授权或 Stripe 失败时"看起来可用却什么都没发生"。 - redirect 语义:成功后浏览器会跳转到 Stripe,因此 mutation 的
isPending会一直持续到页面卸载,redirectError只在失败时出现。 - UI 状态机(见 BillingCard):加载中显示 muted 提示;free 计划显示 "You are on the Free plan" + 升级按钮;active/trialing 显示计划名、状态徽标、续费日期和 "Manage Billing" 按钮;取消中显示琥珀色警告("Access until …" 或 "Renews on …")并提供从门户恢复的入口。
- 按
canManage渲染:非 owner/admin 只看到只读视图,底部提示 "Only organization owners and admins can manage billing.",与插件authorizeReference的判定保持镜像一致(源码注释明确说明这一点)。 - 错误兜底:查询出错时显示 "Could not load billing." 而不是回落到 free 计划分支——注释指出,未知订阅若落入 free 分支,就会让付费客户被告知"没有套餐"。
测试策略:插件测自己的内部,应用测自己的接缝
规范文档明确了测试边界:插件自身测试其内部实现(webhooks、checkout、订阅生命周期、授权);应用层测试项目自己拥有的接缝。Checkout 与 Webhook 全流程不在应用层重测,开发期通过stripe listen验证。
Router 测试(apps/api/routers/billing.test.ts)
apps/api/routers/billing.test.ts 覆盖:
- 无订阅时返回 free 计划默认值(
enabled: true, canManage: true, plan: "free", status: null, periodEnd: null, cancelAtPeriodEnd: false, limits: { members: 1 }) - 活跃/试用订阅正确映射(pro →
limits: { members: 50 },starter trialing →limits: { members: 5 }) - 禁用计费时返回
enabled: false, canManage: false+ free limits cancelAtPeriodEnd标志映射- 未知套餐名直接抛错:
Unknown plan "enterprise"——这正是 UI 注释里"未知订阅不得落入 free 分支"的服务端对应 - 成员关系双重条件查询:测试直接驱动
where回调,断言查询条件是organizationId=org-1 AND userId=user-1,防止只按单一列查询的回归 - session 长于成员关系的安全回归:
memberRole: null时拒绝读取并断言subscription.findFirst未被调用 - 个人计费跳过成员检查:
member.findFirst不被调用 canManage按角色映射:owner/admin → true,member → false(it.each参数化用例)- 响应形状的完整断言
Query 测试(apps/app/lib/queries/billing.test.ts)
apps/app/lib/queries/billing.test.ts 覆盖:
- query key 包含
activeOrgId(["billing", "subscription", "org-123"]) undefined规范化为null(无参数与缺省参数同理)- 不同组织产生不同 key(
org-1≠org-2),保证切换组织自动 refetch billingQueryKey是完整 key 的前缀,可作批量失效(如订阅变更后queryClient.invalidateQueries({ queryKey: billingQueryKey }))
文件地图
| 层 | 文件 |
|---|---|
| Schema | db/schema/subscription.ts、db/schema/user.ts(stripeCustomerId)、db/schema/organization.ts(stripeCustomerId) |
| 服务端 | apps/api/lib/plans.ts(套餐限额单一事实源)、apps/api/lib/auth.ts(stripe 插件配置) |
| Router | apps/api/routers/billing.ts,注册于 apps/api/lib/app.ts(billing: billingRouter) |
| 客户端 | apps/app/lib/auth.ts(stripeClient)、apps/app/lib/queries/billing.ts |
| UI | apps/app/routes/(app)/settings.tsx/settings.tsx) 中的 BillingCard |
| 测试 | apps/api/routers/billing.test.ts、apps/app/lib/queries/billing.test.ts |
配套文档还包括:docs/billing/index.md(功能总览)、docs/billing/plans.md(套餐配置细节)、docs/billing/checkout.md(Checkout 流程与授权细节)、docs/billing/webhooks.md(Webhook 处理)。
实践要点速查
- 启用/禁用计费:四个必需环境变量要么全配要么全不配,部分配置会在认证初始化时抛错。
- 新增或修改套餐:依次更新
planLimits(apps/api/lib/plans.ts)→ 认证插件配置(apps/api/lib/auth.ts)→ Stripe Dashboard 产品/价格 → 环境变量 → 设置页 UI(apps/app/routes/(app)/settings.tsx/settings.tsx))。 - Schema 变更:修改
db/schema/后运行bun db:generate生成迁移。 - 限额执行位置:
billing.subscription只负责"报告"限额,执行要在应用逻辑(tRPC 中间件 / UI 守卫)。 - 组织计费安全:
authorizeReference(mutation 侧)与billing.subscription的成员复查(读取侧)缺一不可,且共用canManageOrgBilling谓词,防止 session 长于成员关系造成越权读取。
- 后端
- 前端
【免费下载链接】react-starter-kit
Modern React starter kit with Bun, TypeScript, Tailwind CSS, tRPC, Stripe, and Cloudflare Workers. Production-ready monorepo for building fast web apps.
相关推荐
React Starter Kit 订阅计费实战指南:基于 Better Auth Stripe 插件的架构、授权与配置详解
React Starter Kit 订阅计费实战指南:基于 Better Auth Stripe 插件的架构、授权与配置详解 React Starter Kit
后端前端InsForge Stripe 支付集成指南:Checkout、订阅、Billing Portal 与 Webhook 履约全流程
InsForge Stripe 支付集成指南:Checkout、订阅、Billing Portal 与 Webhook 履约全流程 导读 本文是基于 InsFo
后端前端AI 应用react-starter-kit 的 Stripe Checkout 订阅流程:从升级跳转到授权与账单 UI 的完整实现
react starter kit 的 Stripe Checkout 订阅流程:从升级跳转到授权与账单 UI 的完整实现 本文基于 react starter
后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考