Kotonia
ログイン今すぐ始める

Kotonia Articles

Stripeでは決済済み、DBでは未契約——二重課金を防ぐまで

Stripeでサブスクリプション課金を実装する開発者向け。決済は成功したのにDB更新がロールバックし、ユーザーの自然な再試行から複数契約が作られた。返金・データ復旧からDB排他、Webhookの冪等化、Sandboxテストまでを実例でまとめる。

著者 5分で読める
#stripe#rust#postgresql#webhook#個人開発
他の言語英語中国語

TL;DR

Stripeでは決済が成功していたが、WebhookのDB更新だけが失敗し、画面は旧プランのままだった。ユーザーが再試行するたびに別の契約が生まれる構造だった。返金とDB復旧後、PostgreSQL排他、未完了Checkoutの永続化、Webhookの状態遷移テストを追加した。

この記事の対象読者

Stripe CheckoutとWebhookでサブスクリプション課金を実装している人。特に、「Stripeの冪等キーを入れたから二重課金は防げる」と考えている人に読んでほしい。

BeforeAfter
再試行ごとに別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の処理は、大まかに次の順番だった。

  1. usersの契約状態を更新する
  2. 契約者用のワークスペースを作る
  3. ユーザーを管理者として追加する
  4. すべて成功したら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つの教訓

  1. ユーザーの再試行を異常と思わない。反映されなければ、もう一度押すのは自然だ。
  2. 既存契約の変更と新規契約の作成を分ける。同じボタンから始まっても意味は異なる。
  3. Idempotency Keyを排他制御の代わりにしない。アプリ側の一意制約と状態管理が必要になる。
  4. Webhookは重複・遅延・逆順を前提にする。イベントIDだけでなく、現在状態と対象IDで適用可否を決める。
  5. Webhookトランザクション内の全操作を再実行可能にするINSERTではなくupsertが必要な場面を疑う。
  6. 外部APIのバージョンとSDKの型定義を揃える。署名検証の成功とペイロードの型変換成功は別問題だ。
  7. テストと定期照合を両方持つ。不整合の予防と検知は別の防御層になる。

Kotoniaでどう活きているか

今回の修正は、Kotoniaの料金ページから始まるサブスクリプション契約とプラン変更に入っている。連打や別タブだけでなく、数分後の再試行も同じ未完了Checkoutに合流する。

あわせて、課金対応は「決済コードを書く」だけではなく、返金、DB復旧、再発防止、検知をひとつの運用として扱うようにした。お金に関わるところは、「うまく動く」より「失敗しても安全に戻れる」ところまで作って、ようやく機能なのだと実感した。


※ 本記事は実際の障害対応をもとにしていますが、請求書番号、メールアドレス、Stripe Customer ID、Subscription IDなどの顧客情報は省略・匿名化しています。

Kotonia は音声AI、AIチャット、画像生成、チーム共有をひとつにまとめたAIワークスペースです。

試してみる