TL;DR
Stripeでは決済が成功していたが、WebhookのDB更新だけが失敗し、画面は旧プランのままだった。ユーザーが再試行するたびに別の契約が生まれる構造だった。返金とDB復旧後、PostgreSQL排他、未完了Checkoutの永続化、Webhookの状態遷移テストを追加した。
この記事の対象読者
Stripe CheckoutとWebhookでサブスクリプション課金を実装している人。特に、「Stripeの冪等キーを入れたから二重課金は防げる」と考えている人に読んでほしい。
| Before | After |
|---|---|
| 再試行ごとに別Customer・別Subscriptionを作成 | 既存Subscriptionを同じオブジェクト上で変更 |
| Webhookの一部失敗で全DB更新をロールバック | 再実行可能なupsertと状態ガード |
| 数秒間の冪等キーに依存 | DB排他と未完了Checkoutの一意制約 |
| Webhookはおおむね到着順に処理される前提 | 重複・遅延・逆順をテスト |
始まりは「反映されないので、もう一度決済した」
個人で運営しているKotoniaに、一件の問い合わせが届いた。
ユーザーは旧プランから上位プランへ変更したつもりだった。しかし画面には旧プランが表示され、「サブスクリプションを管理」にも新しい契約が見えない。反映されていないと考え、もう一度決済した。
一方、メールには請求書が3枚届いていた。この時点で、「画面の表示遅延」ではなく、複数の課金オブジェクトが作られた可能性が高い。
この再試行は異常行動ではない。決済したのに画面が変わらなければ、「送信できていなかったのかな」ともう一度押す。設計側が安全に受け止めるべき操作だった。
StripeとアプリDBで、別の真実が生まれていた
調査すると、Stripeには同じアプリユーザーを示すmetadataを持つ、複数のCustomerとSubscriptionがあった。決済自体も成功していた。
ところがアプリDBは、最初の旧プランのCustomer IDとSubscription IDを持ったままだった。StripeのCustomer PortalはそのCustomer IDにひも付いて開くため、他のCustomerに生まれた契約はユーザーから見えない。
Stripe: 旧プラン + 変更後プラン + 再試行分
アプリDB: 旧プランだけ
Portal: DBが指すCustomerの契約だけ表示
決済システムとアプリのどちらも、自分の持つデータの範囲では正しい。但し、システム全体としては間違っていた。
Webhookは届いたが、トランザクションが失敗した
契約完了Webhookの処理は、大まかに次の順番だった。
usersの契約状態を更新する- 契約者用のワークスペースを作る
- ユーザーを管理者として追加する
- すべて成功したらcommitする
初回契約時には「マイワークスペース」が作成済みだった。プラン変更時も同じ名前でINSERTしたため、一意制約に衝突。トランザクション全体がrollbackされ、1番目のユーザ更新まで消えた。
さらに、使用していたStripeクライアントの型定義よりWebhook EndpointのAPIバージョンが新しく、一部のSubscriptionイベントでデシリアライズに失敗する条件も重なっていた。
単一のバグではない。「新規契約を作れるCheckout」「Webhookトランザクションの再実行性不足」「APIバージョン差」が直列に並び、事故になった。
最初にやったのは、原因修正ではなく返金と復旧
恒久対応より先に、ユーザーが損をしない状態を作った。
- Stripe上のCustomer、Subscription、Invoice、Chargeをメタデータで照合
- 意図した上位プランのSubscriptionを1件だけ残す
- 旧プランと重複分を解約し、該当決済を返金
- アプリDBのCustomer ID、Subscription ID、プランを残した契約に合わせる
- ワークスペースの月次利用上限も新プランに合わせる
調査中に返金済みのChargeもあったため、同じ決済を再度返金しないよう、必ずStripeの最新状態を確認してから操作した。障害対応中は「正しいことを素早くやる」だけでなく、「すでに行われた正しい操作を重複させない」ことも重要になる。
既存契約のプラン変更で、新規Subscriptionを作らない
最も大きな修正は、契約フローを二つに分けたことだ。
有効なSubscriptionがある
→ Billing Portalで同じSubscriptionのPriceを変更
有効なSubscriptionがない
→ Checkout Sessionを作成
既存契約者にCheckoutを開かせるのではなく、Billing Portalのsubscription_update_confirmフローで既存Subscriptionを更新する。これで、プラン変更のたびにCustomerとSubscriptionが増える構造をなくした。
新規Checkoutが必要な場合も、DBにCustomer IDが残っていれば再利用する。初回のみアカウントのメールアドレスを渡し、サポート時に照合できるようclient_reference_idとmetadataにもユーザーIDとプランを保存した。
PostgreSQLの排他と未完了Checkoutで再試行を止める
StripeのIdempotency Key(同じキーのAPI操作を1回として扱う機能)は必要だが、それだけでアプリ全体の一意性は保証できない。
リクエスト間でキーが変われば別操作になる。複数インスタンス構成ではプロセス内のMutexも共有されない。そこで、Checkout作成時にPostgreSQLのユーザー単位advisory transaction lockを取るようにした。
SELECT pg_advisory_xact_lock(:billing_namespace, :user_id);
さらに、Stripe CheckoutのSession ID、プラン、URL、有効期限、状態をDBに保存する。status = 'open'の行は、1ユーザーに1件しか作れない部分一意インデックスを設定した。
CREATE UNIQUE INDEX one_open_checkout_per_user
ON subscription_checkout_sessions (user_id)
WHERE status = 'open';
これにより、同じプランのボタンを連打しても既存のCheckout URLを返す。別タブ、数分後の再試行、複数サーバーからの同時実行でも、未完了Checkoutを2件作れない。別プランの未完了Checkoutがある場合は新規作成を拒否し、Sessionは31分で失効させる。
Webhookは「1回、順番通り」には来ない
Stripe公式のWebhookドキュメントにも、同じイベントが複数回届く可能性と、生成順での配信は保証されないことが明記されている。重複と遅延を異常として扱ってはいけない。
対策として、次のルールを実装した。
- Stripe Event IDを一意保存し、同じイベントの再処理を防ぐ
checkout.session.completedでユーザー行をFOR UPDATEロックする- 別のactive/trialing Subscriptionがあれば、遅れたCheckoutで置き換えない
- 同じSubscriptionの再送は安全に再適用できる
subscription.deletedは、現在DBが指すSubscription IDと一致する場合だけ反映する- ワークスペースとメンバー追加を
ON CONFLICT ... DO UPDATEにする - 新しいStripe APIバージョンを型で読めない場合に、署名検証後の最小フィールドだけを読むフォールバックを持つ
プラン変更はsubscription.updatedで届くため、ユーザーのプランだけでなく、既存ワークスペースの月次上限も同じトランザクションで更新する。
モンキーテストの代わりに、状態遷移を壊す
課金フローは、ブラウザのボタンをランダムに押すモンキーテストと相性が悪い。本物の決済、非同期Webhook、外部のCustomer Portalを含み、結果の完了まで待つと遅くて不安定になる。
今回は、UI操作ではなく課金状態に対するイベント列を生成した。
CheckoutCompleted(正常契約)
CheckoutCompleted(同じイベントの再送)
CheckoutCompleted(遅れて届いた重複契約)
SubscriptionDeleted(過去の契約)
固定ケースに加え、決定論的な乱数シードで50,000ステップの重複・遅延イベントを流した。どの順番でも、意図したSubscription IDとactive状態が別の古いイベントで変わらないことを不変条件にした。
その上で、Stripe Sandboxに実際に接続し、同じIdempotency Keyで2回Checkoutを作成しても、同じSession IDが返ることを確認。テスト用Sessionはその場でexpireした。PostgreSQLにもマイグレーションを適用し、2件目のopen行が一意制約で拒否されることを確認した。
テストでも見つけにくい「外部とDBの分断」
ここまで対策しても、課金システムの不整合をゼロにできるとは言い切れない。StripeでのAPI操作成功と、自分のDBへのcommitを、1つのACIDトランザクションにはできないからだ。
例えば、StripeがCheckoutを作成した直後にネットワークが切れれば、アプリは成功を受け取れない。DB保存に失敗した場合は、追跡できないCheckout Sessionを自動expireするようにしたが、障害パターの空間は広い。
次に効くのはreconciliation(定期照合)だ。毎日、Stripe上の有効SubscriptionとアプリDBの契約状態を比較し、次を通知する。
- 1ユーザーに複数の有効Subscriptionがある
- StripeはactiveだがDBはinactiveになっている
- DBが指すCustomerとSubscriptionの所有関係が合わない
- StripeのPriceとDBのプラン、利用上限が合わない
テストは「起こるはずのない違反を作らない」ためのもの。照合は「それでも起きた違反を早く見つける」ためのもの。課金には両方が必要だと思う。
事故から得た7つの教訓
- ユーザーの再試行を異常と思わない。反映されなければ、もう一度押すのは自然だ。
- 既存契約の変更と新規契約の作成を分ける。同じボタンから始まっても意味は異なる。
- Idempotency Keyを排他制御の代わりにしない。アプリ側の一意制約と状態管理が必要になる。
- Webhookは重複・遅延・逆順を前提にする。イベントIDだけでなく、現在状態と対象IDで適用可否を決める。
- Webhookトランザクション内の全操作を再実行可能にする。
INSERTではなくupsertが必要な場面を疑う。 - 外部APIのバージョンとSDKの型定義を揃える。署名検証の成功とペイロードの型変換成功は別問題だ。
- テストと定期照合を両方持つ。不整合の予防と検知は別の防御層になる。
Kotoniaでどう活きているか
今回の修正は、Kotoniaの料金ページから始まるサブスクリプション契約とプラン変更に入っている。連打や別タブだけでなく、数分後の再試行も同じ未完了Checkoutに合流する。
あわせて、課金対応は「決済コードを書く」だけではなく、返金、DB復旧、再発防止、検知をひとつの運用として扱うようにした。お金に関わるところは、「うまく動く」より「失敗しても安全に戻れる」ところまで作って、ようやく機能なのだと実感した。
※ 本記事は実際の障害対応をもとにしていますが、請求書番号、メールアドレス、Stripe Customer ID、Subscription IDなどの顧客情報は省略・匿名化しています。
