Cal.diy 集成 Stripe 支付:从密钥配置、Connect OAuth 到预约收款与订阅扣费的完整实战指南
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
Cal.diy 将 Stripe 作为其默认的支付基础设施,为「事件类型(Event Type)收款」「Premium 用户名订阅」「团队/组织计费」等场景提供统一支付能力。本指南以 packages/app-store/stripepayment/README.md 的 8 步配置流程为主线,结合仓库源码逐层拆解 Stripe 接入的完整链路——从密钥获取、环境变量设置、Connect OAuth 授权,到支付意图(PaymentIntent)、卡信息留存(SetupIntent)与 Webhook 通知的实际实现,帮助你在一套可复现的步骤内完成支付集成并理解其底层机制。
Stripe 支付集成模块概览
Stripe 应用位于仓库的 packages/app-store/stripepayment 目录,是 Cal.diy App Store 中category: "payment"、variant: "payment"的标准应用。其元数据定义在 packages/app-store/stripepayment/_metadata.ts:
slug: "stripe"、type: "stripe_payment",在系统中以stripe_payment作为支付类型标识;isOAuth: true:该应用通过 Stripe Connect OAuth 完成账号授权,而不是简单地填写 API Key;extendsFeature: "EventType":应用能力直接挂在「事件类型」上,可在创建/编辑预约类型时启用按次收费;installed字段由三个环境变量共同决定:STRIPE_CLIENT_ID、NEXT_PUBLIC_STRIPE_PUBLIC_KEY、STRIPE_PRIVATE_KEY全部存在时才认为应用已安装。
整个目录结构围绕三条主线组织:
- 接入层:api/add.ts 生成 Connect 授权链接,api/callback.ts 处理 OAuth 回调并落库凭证;
- 服务层:lib/PaymentService.ts 实现创建支付、扣款、退款等核心业务,lib/server.ts 封装 Stripe 官方 SDK;
- 配置层:zod.ts 校验应用密钥格式,components/EventTypeAppSettingsInterface.tsx 提供事件类型设置界面。
前置准备:创建 Stripe 账户并开启测试模式
按照 README 的第一步,需要准备一个可用的 Stripe 账号。官方文档特别强调:进行功能验证时,应始终在 Dashboard 右上角的 Test-Mode 开关打开状态下操作。测试模式下产生的密钥(以pk_test_、sk_test_开头)与生产密钥(以pk_live_、sk_live_开头)隔离,测试数据不会影响真实交易,也不会产生实际扣款。
在 Cal.diy 中,测试模式还影响一处细节:从 pages/setup/_getServerSideProps.ts 可以看到,当环境变量NEXT_PUBLIC_IS_E2E被设置时,Connect OAuth 请求中会强制指定country: "US",注释表明这是为了让 E2E 测试在国际化环境下不失败。
获取 API 密钥并配置环境变量
在 Stripe Dashboard 的 API Keys 页面可以找到两类密钥:
| 密钥 | 前缀 | 说明 | 应写入的环境变量 |
|---|---|---|---|
| 可发布密钥(Publishable key) | pk_... | 用于浏览器端加载 Stripe.js | NEXT_PUBLIC_STRIPE_PUBLIC_KEY |
| 私有密钥(Secret key) | sk_... | 用于服务端所有 API 调用 | STRIPE_PRIVATE_KEY |
| Connect 客户端 ID | ca_... | 用于 OAuth 授权流程 | STRIPE_CLIENT_ID |
| Webhook 签名密钥 | whsec_... | 用于校验 Webhook 请求签名 | STRIPE_WEBHOOK_SECRET |
将以上四项写入.env文件后,Stripe 应用即可出现在已安装列表。密钥格式校验定义在 zod.ts 的appKeysSchema中:
export const appKeysSchema = z.object({ client_id: z.string().startsWith("ca_").min(1), client_secret: z.string().startsWith("sk_").min(1), public_key: z.string().startsWith("pk_").min(1), webhook_secret: z.string().startsWith("whsec_").min(1), });也就是说,服务启动时若密钥前缀不匹配(例如把sk_test_...写错位置),会直接触发 schema 校验失败,从源头避免配置错误。
服务端 SDK 的初始化位于 lib/server.ts,它使用STRIPE_PRIVATE_KEY创建 Stripe 实例,并将 API 版本固定为2020-08-27:
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY || ""; const stripe = new Stripe(stripePrivateKey, { apiVersion: "2020-08-27", });订阅与 Premium 相关的扩展环境变量
除了上述四项基础密钥,模块还通过 lib/constants.ts 读取一批与订阅计费相关的环境变量:
| 环境变量 | 用途 |
|---|---|
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY | Premium 用户名月付价格 ID |
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRODUCT_ID | Premium 套餐产品 ID |
NEXT_PUBLIC_STRIPE_TEAM_MONTHLY_PRICE_ID | 团队按座位(per-seat)月付价格 ID |
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID | 电话号码月付价格 ID |
这些变量由 lib/utils.ts 中的getPremiumMonthlyPlanPriceId()、getPerSeatPlanPrice()、getPhoneNumberMonthlyPriceId()等函数消费,分别服务于 Premium 用户名购买与团队订阅场景。其中getPhoneNumberMonthlyPriceId()在变量缺失时会主动抛错,提示STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID env var is not set。
Stripe Dashboard 配置:Connect OAuth 与 Webhook
README 的第 3~8 步全部在 Stripe Dashboard 完成,是连接 Cal.diy 与 Stripe 的关键环节。
开启 Connect OAuth(Standard Accounts)
进入 Stripe Connect Settings,为Standard Accounts激活 OAuth。Standard 模式允许每个 Cal.diy 用户用自己的 Stripe 账户独立收款——平台自身持有STRIPE_PRIVATE_KEY,而每个接入的用户通过 OAuth 获得独立的stripe_user_id,交易在其名下结算。
随后将以下地址登记为 OAuth redirect URL(README 中写作<CALENDSO URL>占位符,实际为部署实例的根地址,即代码中的WEBAPP_URL):
<WEBAPP_URL>/api/integrations/stripepayment/callback从源码看,这个回调端点有三处会生成跳转:
- api/add.ts 使用
client_id与scope: "read_write"、response_type: "code"构造https://connect.stripe.com/oauth/authorize?授权链接,并将当前用户的邮箱、姓名预填进stripe_user参数; - pages/setup/_getServerSideProps.ts 在应用安装页(Setup)执行服务端重定向,把
returnTo、onErrorReturnTo、fromApp等信息编码进state,保证 OAuth 完成后能回到正确的页面; - api/callback.ts 接收授权码,调用
stripe.oauth.token()换取访问令牌,再通过stripe.accounts.retrieve()获取账户默认币种(default_currency),最后调用createOAuthAppCredential将{ appId: "stripe", type: "stripe_payment" }与令牌数据一并写入 Credential 表。
回调端点还处理了用户拒绝授权的场景:当 Stripe 返回access_denied时,会跳转到state.onErrorReturnTo(默认/apps/installed/payment),避免用户卡在死循环里。
创建 Webhook 并订阅 payment_intent 事件
在 Stripe Webhooks 页面添加端点:
<WEBAPP_URL>/api/integrations/stripepayment/webhookREADME 要求为 Webhook选择所有payment_intent事件(即payment_intent.succeeded、payment_intent.payment_failed等),因为预约收款的核心状态都体现在 PaymentIntent 上。创建完成后将whsec_...开头的签名密钥填入STRIPE_WEBHOOK_SECRET。
需要注意一个仓库现状:社区版(Community Edition)的 apps/web/pages/api/integrations/stripepayment/webhook.ts 当前实现会直接返回 404,提示 "Payment webhooks are not available in community edition";而在 apps/web/playwright/fixtures/users.ts 的 E2E 流程中,会使用stripe.webhooks.generateTestHeaderString()构造合法的stripe-signature签名头,向该端点 POST 一个payment_intent.succeeded事件来验证「支付确认」流程是否被正确触发。这提示你:在社区版本地验证时,支付成功回调的端到端链路需要依赖 E2E 工具或自建 Webhook 消费,而不能依赖该端点返回业务结果。
事件类型级支付配置:价格、币种与支付选项
接入成功后,每个事件类型都可以独立启用 Stripe 收费。设置界面由 components/EventTypeAppSettingsInterface.tsx 提供,配置项的数据模型定义在 zod.ts 的appDataSchema中:
| 配置项 | 类型 | 说明 |
|---|---|---|
price | number | 收费金额(以最小货币单位存储) |
currency | string | 币种,默认取currencyOptions首项usd |
paymentOption | ON_BOOKING/HOLD | 支付时机,见下文 |
enabled | boolean | 是否对该事件类型启用收费 |
refundPolicy | enum | 退款策略(来自@calcom/lib/payment/types) |
refundDaysCount | number | 退款天数窗口 |
refundCountCalendarDays | boolean | 退款天数按自然日还是工作日计算 |
autoChargeNoShowFeeIfCancelled | boolean | 取消时是否自动收取爽约费 |
autoChargeNoShowFeeTimeValue/autoChargeNoShowFeeTimeUnit | number / enum | 爽约费计费窗口与单位(minutes/hours/days) |
其中paymentOption的合法取值定义在 lib/constants.ts:
export const paymentOptions = [ { label: "on_booking_option", value: "ON_BOOKING" }, { label: "hold_option", value: "HOLD" }, ];- ON_BOOKING:预约创建时立即创建 PaymentIntent 并完成扣款(即「预订即支付」);
- HOLD:预约时只通过 SetupIntent 留存卡信息、不扣款,之后在特定时机(如爽约)再真正
chargeCard扣款。
设置界面还做了两项保护:启用支付时若未选择币种和支付选项会自动填入默认值(USD / ON_BOOKING),未选择退款策略时默认RefundPolicy.NEVER;若事件类型配置了重复(recurring)规则或开启按席位(seats)预订,界面会显示对应警告——重复事件每个实例都会被收费,这是需要提前告知预约者的事项。币种列表来自 lib/currencyOptions.ts,覆盖 AED、CNY、EUR、USD 等 130+ 种 Stripe 支持的币种。
服务端支付核心:PaymentIntent 与 SetupIntent
支付服务的完整实现在 lib/PaymentService.ts,通过BuildPaymentService(credentials)工厂函数对外暴露(工厂方式避免 Stripe SDK 类型泄漏到.d.ts产物中)。
create:预订即支付(ON_BOOKING)
create()流程:先通过retrieveOrCreateStripeCustomerByEmail按预约者邮箱在收款账号(stripe_user_id)下创建或复用 Stripe Customer,再调用stripe.paymentIntents.create(),关键参数如下:
const params: Stripe.PaymentIntentCreateParams = { amount: payment.amount, currency: payment.currency, customer: customer.id, automatic_payment_methods: { enabled: true }, metadata: { identifier: "cal.com", bookingId, calAccountId: userId, /* ... */ }, }; const paymentIntent = await this.stripe.paymentIntents.create(params, { stripeAccount: this.credentials.stripe_user_id, });注意amount是最小货币单位(如分),这与设置界面中convertToSmallestCurrencyUnit的换算一致。metadata里写入了bookingId、calAccountId、bookerEmail等业务信息,便于在 Stripe Dashboard 中反查订单来源。创建成功后,模块会在本地Payment表中落一条记录,externalId指向 PaymentIntent ID,并保存stripe_publishable_key与stripeAccount供前端展示支付界面使用。
collectCard + chargeCard:先留存后扣款(HOLD)
HOLD 模式分为两步:
collectCard():创建 SetupIntent(仅收集卡信息,payment_method_types: ["card"]),对应 Payment 记录以 SetupIntent ID 作为externalId;chargeCard():在需要扣款时(如确认爽约),先校验 Stripe Customer 与支付方式仍存在,再创建off_session: true、confirm: true的 PaymentIntent 完成后台扣款,成功后把 Payment 记录标记为success: true并合并 PaymentIntent 数据。
chargeCard()对常见扣款失败做了用户友好化映射,例如 "your card was declined" 会转换为内部错误码your_card_was_declined,供前端展示本地化文案。
refund 与 deletePayment
refund()基于payment.externalId(PaymentIntent ID)调用stripe.refunds.create(),仅在支付成功且未退款时执行;退款成功后更新本地记录refunded: true;deletePayment()用于预约被取消时清理:先列出并过期所有关联的 Checkout Session,再取消 PaymentIntent,保证不会出现「预约已取消但待支付订单仍有效」的脏状态。
支付链接与前端加载
预约者付款时,Cal.diy 会生成一个独立的支付落地页链接,其构造逻辑在 lib/client/createPaymentLink.ts:
export function createPaymentLink(opts: { paymentUid, name?, date?, email?, absolute? }): string { let link = ""; if (absolute) link = WEBSITE_URL; const query = stringify({ date, name, email }); return `${link}/payment/${paymentUid}?${query}`; }即每个 Payment 记录的唯一uid对应一个公开支付页 URL,name、date、email作为查询参数预填。前端 Stripe.js 的加载则由 lib/client/getStripe.ts 完成,它使用loadStripe并采用单例模式(stripePromise只初始化一次),避免在 SPA 中重复加载 SDK。
订阅场景:Premium 用户名与团队计费
除事件类型收费外,Stripe 还支撑平台级订阅业务。
Premium 用户名订阅
api/subscription.ts 处理 Premium 用户名购买:校验用户已存在 Stripe Customer 后,创建mode: "subscription"的 Checkout Session,line_items引用getPremiumMonthlyPlanPriceId()返回的价格 ID,并开启allow_promotion_codes。Session 的success_url与cancel_url都指向:
<WEBAPP_URL>/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=...支付结果回调
api/paymentCallback.ts 通过 lib/getCustomerAndCheckoutSession.ts 拉取 Checkout Session 与 Customer,再按「Stripe Customer 邮箱 → 用户 metadata 中的stripeCustomerId」两级策略定位平台用户。核心分支如下:
payment_status !== "paid":跳回回调页并携带paymentStatus参数,前端据此显示支付失败/未完成状态;- 支付成功:把目标用户名写入用户记录并将
metadata.isPremium置为true;随后通过VerificationTokenService.create()生成有效期 1 天的验证令牌,并调用sendVerificationRequest向用户邮箱发送验证登录链接,完成「支付 → 领取 Premium 用户名」的闭环。
相关逻辑有配套单测覆盖:api/tests/paymentCallback.test.ts 与 lib/VerificationTokenService.test.ts。
测试与验证
仓库为 Stripe 集成提供了多层验证手段:
- OAuth 安装流程:pages/setup/tests/_getServerSideProps.test.ts 验证未登录跳转、
client_id缺失、授权链接构造等分支; - 支付回调流程:api/tests/paymentCallback.test.ts 覆盖用户定位与支付状态分支;api/tests/portal.test.ts 覆盖计费门户;
- 验证令牌:lib/repositories/VerificationTokenRepository.test.ts 覆盖令牌存取;
- E2E 支付确认:apps/web/playwright/fixtures/users.ts 模拟完整流程——支付完成后从 URL 提取
payment_intent参数,构造payment_intent.succeeded事件并用stripe.webhooks.generateTestHeaderString()生成签名,POST 到 Webhook 端点,最后断言返回 200。
本地联调时可参考这套 E2E 的报文结构:事件类型为payment_intent.succeeded,事件对象携带{ id: paymentIntentId },并额外传入account字段标识收款账号。
配置清单速查
按 README 的 8 个步骤整理一份最终核对表:
- 创建/复用 Stripe 账户,测试阶段开启 Test-Mode;
- 从 API Keys 页面复制
pk_...→NEXT_PUBLIC_STRIPE_PUBLIC_KEY,sk_...→STRIPE_PRIVATE_KEY; - 在 Stripe Connect Settings 激活 Standard Accounts 的 OAuth;
- 将
<WEBAPP_URL>/api/integrations/stripepayment/callback添加为 redirect URL; - 复制客户端 ID
ca_...→STRIPE_CLIENT_ID; - 在 Webhooks 页面添加
<WEBAPP_URL>/api/integrations/stripepayment/webhook; - 为 Webhook 勾选全部
payment_intent事件; - 复制
whsec_...→STRIPE_WEBHOOK_SECRET。
完成上述配置并重启服务后,即可在事件类型设置中启用「需要付款」,并为每个预约类型指定价格、币种、支付时机与退款/爽约策略。若你在社区版中遇到 Webhook 端点的 404 响应,请对照 apps/web/pages/api/integrations/stripepayment/webhook.ts 的现状确认版本行为,并以测试模式 + E2E 脚本先行验证支付主链路。
【免费下载链接】cal.diyScheduling infrastructure for absolutely everyone.项目地址: https://gitcode.com/GitHub_Trending/ca/cal.diy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考