ビルドが 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 には入ったのにデータベースには届かなかった課金という形で支払わされます。
