02运维5 min

Stripe 弄坏我的 build 五次了,现在我把它排进了日程

升级 Stripe 的 API 版本不是一次事故。它是周期性的维护,附带损伤相当可预测,值得提前给它留预算。

作者 Salva Castells

封面图: Bernd Schwabe in Hannover, CC BY-SA 4.0.
本文目录

build 挂了,报的是 lib/stripe.ts 里的类型错误。那一行是这样:

Type '"2025-06-30.basil"' is not assignable to type '"2025-07-30.basil"'.

我根本没碰过支付。实际发生的是,bun update 拉来了新版本的 Stripe SDK,而这个包内部带着一个写死它所支持 API 版本的字面量。这个字面量一变,所有显式传 apiVersion 的地方就都编译不过。这件事在一年多一点的时间里,在四个不同项目上发生了五次。

第一次是事故。到第五次,它已经是我预料中的事了。

同一个提交,四个仓库,相隔一年

这个序列写在历史记录里,重复到几乎有点滑稽。八月,2025-06-30.basil 到 2025-07-30.basil。一个月后,2025-07-30.basil 到 2025-08-27.basil,同一天在两个仓库里。几个月后,在一个图像生成的 SaaS 里,2025-11-17.clover 到 2026-01-28.clover。没过多久,又一次,在我用来起项目的脚手架里。

提交信息一模一样:"update Stripe API version to X"。diff 也一样。而第一课就在这里,而且和 Stripe 一点关系都没有:同一个字符串在同一个仓库里重复了四处。一处在 webhook 里,另外三处在 createCheckout、createCustomerPortal 和 findCheckoutSession 里,各自都有自己的 new Stripe(secretKey, { apiVersion, typescript: true })。四行一模一样、每次都要手改的代码,以及四个可能漏掉一处的地方。

我还厚着脸皮在每一处旁边留了注释:

const stripe = new Stripe(secretKey, {
  apiVersion: "2026-01-28.clover", // TODO: update this when Stripe updates their API
  typescript: true,
});

一个重复了四遍的 TODO,等于白纸黑字记下了:你知道自己会重复这份工作,而你依然没有把它收拢到一处。

附带损伤不在版本号上,在类型上

弄坏 build 的那部分很简单:改掉字符串就编译过了。真正花代价的是藏在同一次升级里的形状变化,因为 SDK 会同时重写它的类型。

具体吃掉我一个下午的是 invoice.paid 的 webhook。我原来是这样取 price ID 的:

const priceIdIP = stripeIP.lines.data[0].pricing?.price_details?.price;

在新版本里,这个字段变成了 string | Price | undefined,取决于它有没有被 expand。TypeScript 不再接受和 p.priceId 的比较,而更重要的是,运行时开始拿一个对象去和一个字符串比较:在价格表上的 find 什么都找不到,落进 if (!pricingIP || !priceIdIP),于是这笔支付没有写进数据库。钱在 Stripe 那边收了,用户却没有拿到额度。

第一版修复是显而易见的那个:

const rawPrice = stripeIP.lines.data[0].pricing?.price_details?.price;
const priceIdIP = typeof rawPrice === "string" ? rawPrice : rawPrice?.id;

而我当天就得再改一次,因为 rawPrice?.id 在所有分支上仍然没有正确收窄类型,在不同的 checkout 模式下编译就是错的。最后留下的版本更丑,也更诚实:

const priceIdIP =
  typeof rawPrice === "string"
    ? rawPrice
    : typeof rawPrice === "object" && rawPrice
      ? (rawPrice as { id: string }).id
      : undefined;

折扣也一样。discounts: couponId ? [{ coupon: couponId }] : [] 和 allow_promotion_codes: true 共存,而这里有一个 Stripe 不留情面的交互:如果你传了固定优惠券,客户的促销码就不再生效。同一天两个提交才把它弄对。这两个都不是因为版本:都是因为动了 checkout。

为什么我最后不再固定版本了

第五次升级之后,我做了和正统说法相反的事。我把四处的 apiVersion 都删了:

const stripe = new Stripe(secretKey);

不写 apiVersion 时,SDK 用它内部编译进去的版本。而那正是你正在使用的这套 TypeScript 类型所对应的版本。弄坏 build 的那种不一致从构造上就消失了:只有一个来源,你就不可能让 SDK 在一个版本而字面量在另一个版本。

固定版本的经典理由是这样你能控制行为何时改变。这没错,但当你同时固定版本又每个月更新 SDK 时,它就不成立了。那时你手上并没有两个开关:你有一个开关,加一个要在四个文件里手动同步的字符串。真正的锁定来自 lockfile,不是那个字面量。

如果你真的想钉住 API 版本,这个锁必须只在一个地方、被导出,而且 SDK 必须在 package.json 里钉死到确切版本,不能用 ^。钉住一个而让另一个浮动,是两边最差的组合。

新版本出来时的检查清单

  • 同时更新 SDK 和字面量,绝不分开。分开的话,类型错误会出现在一个跟支付毫无关系的 PR 里。
  • 在整个仓库里搜,不要只看那个显眼的文件。 五次里我都在一个自己不记得的地方找到了第五处 new Stripe(。
  • 在认可绿色 build 之前重读 webhook handler。 编译器会提醒你 apiVersion,但一个从 string 变成对象的字段,只要中间有个 ?.,就可能编译通过而在运行时失败。
  • 用 CLI 测 invoice.paid,不要只测 checkout.session.completed。周期性事件走的是另一条数据路径,坏了也没人知道,直到下个月。
  • 如果会话带着优惠券和促销码,就一起测它们。

我的错误是把每次升级都当成打断。它不是:版本每两三个月出一次,如果你记得看 webhook,半小时就够;不记得,就是一个下午。这就是维护,而维护是要排进日程的。不排进日程的部分,会以"钱进了 Stripe 却没进数据库"的形式来收账。