支付

Stripe 和 Creem 都已接好。结账、webhook、套餐变更和退款如何运作,怎么选支付商,以及如何在本地测试。

ShipKit 卖两类东西:订阅(一个套餐,按月或按年计费)和一次性购买(积分包)。两者走同一条流水线,两家支付商也都已经实现,所以选哪一家只是一项配置,不需要改代码。

一笔购买的完整路径

  1. 已登录用户点击购买按钮。服务端函数(src/features/billing/server/fns.ts 里的createSubscriptionCheckoutFn,src/features/credits/server/fns.ts 里的createCreditCheckoutFn)向当前支付商申请一个托管结账页,并把用户 id 和购买内容(kind、planId 或 packId)写进结账的 metadata。
  2. 买家在支付商的页面付款,然后被带回 /billing 或 /credits,语言和离开时一致。
  3. 支付商向 /api/webhooks/stripe 或 /api/webhooks/creem 推送 webhook。这个路由只做三件事:验签;按事件 id 去重(webhook_events 表里的唯一行);把 payload 翻译成与支付商无关的PaymentEvent。
  4. 每个关心钱的功能模块各自处理这些事件:billing 同步 subscriptions 和 orders 并发收据,credits 发放积分,affiliate 记录佣金,audit 和 analytics 留下记录。

第 2 步本身也能完成结算。回跳 URL 带着结账 id,账户对话框会调用reconcileCheckoutFn(src/core/payment/fns.ts):它去问支付商这笔结账的结果,用同一个翻译器处理,派发同样的事件。每个处理器都以外部订单 id 或订阅 id 为键,所以 webhook 和回跳谁先到谁干活,后到的什么也不做。webhook 就算一直没来,也只是晚一点到账,不会丢单。

已经有有效订阅的用户不会再拿到第二个结账页:购买按钮会把他带到 /billing,在那里换套餐是一次带报价的切换。

Stripe 还是 Creem

StripeCreem
角色支付处理方,你自己是登记商户(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 里的目录 idstripePriceId,每个档位、每种周期各一个creemProductId,每个档位、每种周期各一个
开关PAYMENT_PROVIDER=stripePAYMENT_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 地址和正式密钥,见部署。