React Starter Kit 的 Stripe Billing 集成指南:基于 Better Auth 插件实现订阅、Checkout 与组织级计费
2026/9/21 16:03:10 网站建设 项目流程
  • 后端
  • 前端

【免费下载链接】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.

项目地址:https://gitcode.com/gh_mirrors/rea/react-starter-kit
点击查看免费下载

本篇技术指南围绕 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"的排查困境。

关键决策理由

规范文档给出了三项核心选型决策,均可在源码中得到印证:

  1. Better Auth 插件而非裸 Stripe SDK:项目本就用 Better Auth 处理认证、组织和 session(见 apps/api/lib/auth.ts 中createAuth的插件列表:anonymous()organization()passkey()emailOTP())。插件顺带承担了客户同步、订阅生命周期、Webhook 验签与组织级计费,省去了大量胶水代码。

  2. Hosted Checkout 而非嵌入式 Elements:Stripe Checkout 开箱即满足 PCI 合规、无需引入@stripe/stripe-js客户端依赖,自动处理支付方式选择、3D Secure 和收据。项目保留未来升级嵌入式 Elements 的路径,但初期不需要。

  3. 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) └───────────────┘

完整的数据流为:

  1. 用户点击Upgrade——Better Auth 客户端调用auth.subscription.upgrade()
  2. 插件创建 Stripe Checkout session,302 重定向浏览器到 Stripe
  3. 用户完成支付——Stripe 向/api/auth/stripe/webhook发送 Webhook
  4. 插件验签并更新subscription
  5. 客户端通过 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:订阅表由插件全权管理

插件依赖userorganization表上的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):

字段类型/默认值说明
idtext主键通过generateAuthId("subscription")生成前缀 ID
plantext notNull套餐名(starter / pro 等)
referenceIdtext notNull多态引用:指向user.idorganization.id,取决于个人/组织计费
stripeCustomerIdtextStripe 客户 ID
stripeSubscriptionIdtext uniqueStripe 订阅 ID
statustext default "incomplete"订阅状态
periodStart/periodEndtimestamp计费周期
trialStart/trialEndtimestamp试用期
cancelAtPeriodEndboolean default false周期末是否取消
cancelAt/canceledAt/endedAttimestamp取消相关时间戳
seatsinteger席位
billingIntervaltext计费周期
stripeScheduleIdtext计划变更延后到周期末生效时用的 Stripe schedule ID,应用或取消后清除

表上建有subscription_reference_id_idxsubscription_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 }, })

由此可以看到三个重要细节:

  1. Pro 计划带 14 天免费试用freeTrial: { days: 14 })与年付折扣annualDiscountPriceId,指向可选的STRIPE_PRO_ANNUAL_PRICE_ID)。配置了年付 Price ID 后,Stripe Checkout 会自动同时展示月付与年付选项。
  2. free 层没有对应的 Stripe 套餐——没有活跃订阅的用户即视为 free。limits对象会随订阅写入 Stripe 元数据并由插件返回。
  3. 逃生舱(escape hatch):插件接受plans: () => StripePlan[]动态函数形式。规范文档明确建议仅在出现真实的运行时计划管理需求时(例如后台管理系统的套餐 CRUD)才切换到这个形式。

限额执行limits对象由billing.subscriptiontRPC 查询返回,但限额必须在应用逻辑中执行(tRPC 中间件做服务端校验、UI 守卫做客户端拦截),插件本身不强制限额。例如"添加成员"这类操作应在业务代码里对照limits.members做检查。

在 Stripe Dashboard 中创建套餐

每个付费套餐都需要在 Stripe Dashboard 中创建 Product 与 Price:

  1. 创建一个产品(例如 "Starter Plan")
  2. 添加一个周期性价格(例如 $9/月)
  3. 将 Price ID(price_...)复制到对应的环境变量
套餐环境变量产品示例
StarterSTRIPE_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_KEYsk_Stripe 密钥
STRIPE_WEBHOOK_SECRETwhsec_Webhook 签名密钥
STRIPE_STARTER_PRICE_IDprice_Starter 套餐价格 ID
STRIPE_PRO_PRICE_IDprice_Pro 套餐价格 ID
STRIPE_PRO_ANNUAL_PRICE_IDprice_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.local

Raw Body 要求

Stripe Webhook 验签需要原始请求体。插件通过request.text()自行处理,无需任何额外的 Hono 中间件

客户端与 UI:设置页的 BillingCard

客户端侧由三部分组成:

  1. stripeClient:在 apps/app/lib/auth.ts 中注册stripeClient({ subscription: true }),为 auth client 挂载auth.subscription.upgrade()auth.subscription.billingPortal()
  2. 查询与变更 hooks:apps/app/lib/queries/billing.ts 定义billingQueryKey["billing", "subscription"],用于批量失效)、billingQueryOptions(activeOrgId)useBillingQueryuseUpgradeSubscriptionuseBillingPortal
  3. 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(无参数与缺省参数同理)
  • 不同组织产生不同 keyorg-1org-2),保证切换组织自动 refetch
  • billingQueryKey是完整 key 的前缀,可作批量失效(如订阅变更后queryClient.invalidateQueries({ queryKey: billingQueryKey })

文件地图

文件
Schemadb/schema/subscription.ts、db/schema/user.ts(stripeCustomerId)、db/schema/organization.ts(stripeCustomerId
服务端apps/api/lib/plans.ts(套餐限额单一事实源)、apps/api/lib/auth.ts(stripe 插件配置)
Routerapps/api/routers/billing.ts,注册于 apps/api/lib/app.ts(billing: billingRouter
客户端apps/app/lib/auth.ts(stripeClient)、apps/app/lib/queries/billing.ts
UIapps/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 处理)。

实践要点速查

  1. 启用/禁用计费:四个必需环境变量要么全配要么全不配,部分配置会在认证初始化时抛错。
  2. 新增或修改套餐:依次更新planLimits(apps/api/lib/plans.ts)→ 认证插件配置(apps/api/lib/auth.ts)→ Stripe Dashboard 产品/价格 → 环境变量 → 设置页 UI(apps/app/routes/(app)/settings.tsx/settings.tsx))。
  3. Schema 变更:修改db/schema/后运行bun db:generate生成迁移。
  4. 限额执行位置billing.subscription只负责"报告"限额,执行要在应用逻辑(tRPC 中间件 / UI 守卫)。
  5. 组织计费安全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.

项目地址:https://gitcode.com/gh_mirrors/rea/react-starter-kit
点击查看免费下载
上一篇:如何使用Chat UI Kit React快速搭建高颜值聊天界面:完整入门指南
下一篇:NUKE构建系统调试技巧:如何在本地高效测试CI/CD脚本

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询