支付
Stripe 和 Creem 都已接好。结账、webhook、套餐变更和退款如何运作,怎么选支付商,以及如何在本地测试。
ShipKit 卖两类东西:订阅(一个套餐,按月或按年计费)和一次性购买(积分包)。两者走同一条流水线,两家支付商也都已经实现,所以选哪一家只是一项配置,不需要改代码。
一笔购买的完整路径
- 已登录用户点击购买按钮。服务端函数(
src/features/billing/server/fns.ts里的createSubscriptionCheckoutFn,src/features/credits/server/fns.ts里的createCreditCheckoutFn)向当前支付商申请一个托管结账页,并把用户 id 和购买内容(kind、planId或packId)写进结账的 metadata。 - 买家在支付商的页面付款,然后被带回
/billing或/credits,语言和离开时一致。 - 支付商向
/api/webhooks/stripe或/api/webhooks/creem推送 webhook。这个路由只做三件事:验签;按事件 id 去重(webhook_events表里的唯一行);把 payload 翻译成与支付商无关的PaymentEvent。 - 每个关心钱的功能模块各自处理这些事件:billing 同步
subscriptions和orders并发收据,credits 发放积分,affiliate 记录佣金,audit 和 analytics 留下记录。
第 2 步本身也能完成结算。回跳 URL 带着结账 id,账户对话框会调用reconcileCheckoutFn(src/core/payment/fns.ts):它去问支付商这笔结账的结果,用同一个翻译器处理,派发同样的事件。每个处理器都以外部订单 id 或订阅 id 为键,所以 webhook 和回跳谁先到谁干活,后到的什么也不做。webhook 就算一直没来,也只是晚一点到账,不会丢单。
已经有有效订阅的用户不会再拿到第二个结账页:购买按钮会把他带到 /billing,在那里换套餐是一次带报价的切换。
Stripe 还是 Creem
| Stripe | Creem | |
|---|---|---|
| 角色 | 支付处理方,你自己是登记商户(merchant of record) | 登记商户,他们卖,你收钱 |
| 销售税与增值税 | 自己处理,或开启 Stripe Tax | 由对方代收代缴 |
| 应用内变更套餐 | 升级立即生效并补差价,降级在周期结束时生效 | 不支持;切换按钮被禁用,用户需取消后重新订阅 |
| 升级差价全额退款 | 撤销升级:恢复原价,收回补发的积分 | 不适用 |
| 续费扣款失败 | 显示逾期横幅,并发一封提醒邮件 | 什么都不发,周期到期即失效 |
| 删除账户 | 通过 API 抹除 Stripe 客户信息 | 需在 Creem 后台手动删除客户 |
| 本地 webhook 测试 | bun run stripe:listen,通过 Stripe CLI | 用 /creem skill 发送带签名的 payload,或用 ngrok 之类的隧道 |
| 测试模式 | 使用 sk_test_ 密钥 | 使用 creem_test_ 密钥,自动指向 Creem 测试 API |
plans.ts 里的目录 id | stripePriceId,每个档位、每种周期各一个 | creemProductId,每个档位、每种周期各一个 |
| 开关 | PAYMENT_PROVIDER=stripe | PAYMENT_PROVIDER=creem |
其余部分两家完全一致:结账、续费、取消、全额与部分退款,以及挂在每个事件上的积分发放。
选择当前支付商
PAYMENT_PROVIDER 只决定新的结账走哪一家。已有订阅在哪一家开始,就继续在哪一家续费,门户和取消按钮也照常指向那一家,依据是订阅那一行记录的 provider。以后切换不会把任何人晾在半路,两家也可以同时配置。
如果 PAYMENT_PROVIDER 留空:设置了 STRIPE_SECRET_KEY 就用 Stripe,否则用 Creem。在当前支付商的密钥配好之前,什么都买不了:每个购买按钮都会先检查这一点,显示“即将开放”,而不是弹出一个注定失败的结账页。这段时间里,免费套餐每月的积分让应用照样能用。这一点很重要,因为商户账号的审核可能要几周。
密钥是 STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET 和 CREEM_API_KEY / CREEM_WEBHOOK_SECRET。环境变量在启动时读取,所以改完 .dev.vars 要重启开发服务器。完整列表见配置,生产环境的密钥见部署。
套餐和积分包:src/config/plans.ts
这个文件是唯一的数据源。每个套餐有按周期区分的展示价格(单位为分)、一个 monthlyCredits发放量,以及每个周期对应的支付商 id;每个积分包有积分数、价格,以及每家支付商各一个 id。webhook 翻译器会拿实际扣费的 price id 或 product id 回到这里查出对应套餐,所以不在这个文件里的 id,应用就不知道该怎么履约。
自带的 id 都是占位符(price_REPLACE_ME_…)。运行 /stripe 或 /creem skill:它会创建商品目录、把真实 id 写进来,并把 webhook 端到端验证一遍。展示价格必须和支付商那边一致;实际扣多少钱,永远以支付商的价格为准。
在 ShipKit 里,订阅是一笔持续的积分发放,而不是功能开关。文件顶部的定价常量,以及你的产品如果按功能而不是按用量收费该怎么办,见积分和按量计费。
Webhook 事件
| 事件 | 含义 |
|---|---|
subscription.activated | 订阅的第一次付款 |
subscription.updated | 一次续费,或套餐、周期、取消标记发生了变化 |
subscription.payment_failed | 续费扣款失败,支付商稍后会重试(仅 Stripe) |
subscription.canceled | 订阅已结束 |
order.paid | 钱到账了:首付、续费、积分包、升级差价 |
order.refunded | 钱退回去了,全额或部分,或者争议败诉 |
Stripe 的 endpoint 需要订阅这些事件:checkout.session.completed、checkout.session.async_payment_succeeded、invoice.paid、invoice.payment_failed、customer.subscription.updated、customer.subscription.deleted、charge.refunded、credit_note.created 和 charge.dispute.closed。/stripe skill 里也列了这份清单。
只要有一个处理器抛错,webhook 路由就会删掉这条去重记录并返回 500,这样支付商的重试能完整地再跑一遍,而不会被当成重复事件丢掉。收到的 payload 默认保留 90 天(后台设置里的 payment.webhook_retention_days),之后由每晚的定时任务清理。
想在自己的功能里响应支付,注册一个处理器,再在 src/core/payment/handlers.ts 里加一行 import:
// src/features/<name>/server/events.ts
import { onPaymentEvent } from '@/core/payment/events'
onPaymentEvent(async (event) => {
if (event.type !== 'order.paid' || event.meta.kind !== 'my_thing') return
// Key every write on event.externalOrderId: webhooks are redelivered,
// and the checkout return dispatches the same event.
})
功能模块从不直接引入 Stripe 或 Creem 的客户端。新建结账、打开门户、取消订阅,都通过 src/core/payment/provider.ts。
套餐变更(仅 Stripe)
规则写在 src/features/billing/plan-change.ts,买家确认时看到的报价,和实际扣费用的是同一段代码。
- 同周期升级:立即生效,续费日期不变。买家补上差价(年付套餐按一年里剩余的月数计,当月算在内)。本月的积分额度按两档额度之差补足,不管此前已经用掉多少。
- 月付升年付:从今天开始一个新的年付周期,扣掉月付周期里没用完的天数。
- 降级:任何降到更低档位的变更,以及任何年付转月付,都通过 Stripe 的 subscription schedule在已付周期结束时生效。在此之前,待生效的变更会显示在界面上,也可以撤销。
订阅行上的一条条件更新充当短时锁,所以双击也不会把升级扣两次钱。
从应用里取消订阅只会设置 cancelAtPeriodEnd;在支付商报告订阅结束之前,它一直有效。如果续费或取消的 webhook 一直没来,每晚的 billing.expire-stale-subscriptions 任务会在周期结束3 天后把订阅标为过期(billing.grace_days,可在后台修改)。
退款
在支付商后台发起退款,应用跟着 webhook 走。只有全额退款才会撤销这笔订单买到的东西:
- 积分包的积分被收回(如果已经花掉,余额可能变成负数);
- 订阅付款(最近一笔)会结束它买下的付费积分额度:本月额度回落到免费档的数量。订阅那一行本身不变;如果退款意味着客户要走,请在支付商那边取消订阅;
- 升级差价退款会撤销升级:恢复原价,周期不变,收回本月补发的积分;
- 这笔订单上尚未结算的推广佣金作废。
部分退款会被记录下来,视为善意补偿,不收回任何东西。之后如果想扣回积分,用后台的手动调整(见积分和按量计费)。
本地测试
Stripe。 安装 Stripe CLI,把测试密钥写进 .dev.vars,然后:
bun run stripe:listen # prints the whsec_… value for STRIPE_WEBHOOK_SECRET
它会把 webhook 转发到 localhost:3000/api/webhooks/stripe。在界面里用卡号 4242 4242 4242 4242买一次,然后查看本地 D1 里的 webhook_events、orders、subscriptions 和 credit_ledger。stripe trigger checkout.session.completed 能验证验签和去重这条路径,但它的测试数据里没有用户 id,所以不会发放任何东西。
Creem。 Creem 访问不到 localhost,也没有转发用的 CLI。/creem skill 会向本地 endpoint发送一个带签名的假 checkout.completed,不用真的购买就能验证整条流水线。想接收真实的测试模式webhook,就用隧道把 3000 端口暴露出去,把 Creem 的 webhook 指向https://<tunnel>/api/webhooks/creem。隧道必须原样转发请求体,因为签名覆盖的是确切的字节。
生产环境的 webhook 地址和正式密钥,见部署。