02運用5 min

Stripe に五回ビルドを壊されたので、もう予定に入れています

Stripe の API バージョンを上げるのは事故ではありません。付随する被害もかなり予測できる、繰り返しの保守作業です。予算に入れておくべきものです。

著者: Salva Castells

カバー画像: Bernd Schwabe in Hannover, CC BY-SA 4.0.
この記事の内容

ビルドが 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"。差分も同じ。そして最初の教訓がそこにあり、それは 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 は、同じ作業を繰り返すとわかっているのに集約しなかった、という記録です。

付随する被害はバージョンではなく型にある

ビルドを壊す部分は些細です。文字列を変えればコンパイルできます。本当に手間なのは、同じ更新に隠れてくる形の変化です。SDK が同時に型を書き直すからです。

午後を一つ食われた具体例は invoice.paid の webhook でした。price ID をこう取り出していました。

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

新しいバージョンでは、このフィールドは展開されているかどうかで string | Price | undefined になります。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 の型が生成された対象のバージョンです。ビルドを壊していた不一致は構造上消えます。情報源がひとつしかないなら、SDK が一方のバージョンでリテラルが別のバージョン、という状態にはなりえません。

固定する古典的な論拠は、挙動が変わるタイミングを自分で制御できる、というものです。それは正しいのですが、バージョンを固定しながら同時に毎月 SDK を更新するなら成り立ちません。そこにあるのは二つのレバーではなく、レバーひとつと、四つのファイルで手作業で同期する文字列ひとつです。本当の固定はロックファイルがやっていて、リテラルではありません。

本気で API バージョンを固定したいなら、固定は一か所にまとめて export し、SDK は ^ ではなく正確なバージョンで package.json に打ち込むべきです。片方を固定してもう片方を浮かせておくのは、両方の世界の悪いところ取りです。

新しいバージョンが出たときのチェックリスト

  • SDK とリテラルを同時に更新する。 別々にしない。別々にすると、決済の話をしていない PR で型エラーが出ます。
  • 明らかなファイルだけでなくリポジトリ全体を検索する。 五回とも、覚えていなかった場所で五つめの new Stripe( を見つけました。
  • ビルドが緑でも、webhook のハンドラを読み直してから納得する。 コンパイラは apiVersion については教えてくれますが、string からオブジェクトに変わったフィールドは、途中に ?. があるとコンパイルは通ってランタイムで失敗します。
  • checkout.session.completed だけでなく invoice.paid を CLI で試す。 継続課金のイベントは別のデータ経路を通り、翌月まで誰も気づかないまま壊れます。
  • セッションがクーポンとプロモーションコードを持つなら、両方まとめて確認する。

自分の間違いは、毎回の更新を割り込みとして扱ったことでした。そうではありません。二、三か月に一度バージョンが出て、webhook を見ることを覚えていれば三十分、忘れていれば一晩です。それは保守であり、保守は予定に入れるものです。予定に入れないものは、Stripe には入ったのにデータベースには届かなかった課金という形で支払わされます。