Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

2タブで完了した2本のPaymentIntent、負けた側のwebhookが与信を巻き戻す

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PI idを照合しないUPDATEのWHERE句は、負けたPaymentIntentのwebhookイベントが採用中の与信状態を書き換えるのを防げない。

結論

カード仮押さえ(manual capture)のCheckoutを顧客が2タブで完了させると、同じ契約に対して2本のPaymentIntentができる。契約行に記録されるのは先にclaimした「勝者」のPI idだけで、負けたPIに対するsucceeded/canceledのwebhookイベントは、stripe_payment_intent_idを照合しないUPDATEのWHERE句を通って、まだ生きている勝者側の与信状態を書き換えてしまっていた。

症状

カード仮押さえレールは、審査確定まで課金を待つために payment_intent.capture_method: manual のCheckoutを使う。契約行への記録は次のUPDATEで行っていた(修正前・608ee58時点)。

const { data: held } = await admin
  .from("contracts")
  .update({
    stripe_hold_status: "authorized",
    stripe_payment_intent_id: paymentIntentId,
    payment_method: "card",
  })
  .eq("id", contractId)
  .is("stripe_hold_status", null)
  .select("id")
  .maybeSingle();
if (held) {
  // Slack通知・監査ログ
}
return { contractId };

stripe_hold_statusnull の間だけ更新が通るため、同じ契約に対して2本目の checkout.session.completed が届いても2本目はclaimに負け、heldnull のまま静かに終わる。ここまでは意図どおりだが、負けた側のPaymentIntent自体はStripe上でrequires_captureのまま残り続ける。 何も解放しないため、顧客のカードには最大7日、使われない与信枠が確保されたままになる。

さらに、payment_intent.succeededpayment_intent.canceled のハンドラは、対象行を次の条件だけで絞り込んでいた(同じく 608ee58 時点)。

// payment_intent.succeeded
if (pi.metadata?.kind === "hold") {
  await admin
    .from("contracts")
    .update({ stripe_hold_status: "captured" })
    .eq("id", contractId)
    .eq("stripe_hold_status", "authorized");
}
// payment_intent.canceled
const { data: released } = await admin
  .from("contracts")
  .update({ stripe_hold_status: "released" })
  .eq("id", contractId)
  .eq("stripe_hold_status", "authorized")
  .select("id")
  .maybeSingle();

どちらも contracts.idstripe_hold_status = 'authorized' だけで行を絞り込んでおり、どのPaymentIntentからのイベントかは見ていない。 負けたPIが7日後に自動失効して payment_intent.canceled が届くと、contractId は同じで stripe_hold_status も勝者側がまだ authorized のままなので条件に一致し、生きている勝者側の与信を released に書き換えてしまう。

原因

stripe_payment_intent_id は契約行に記録されているのに、状態遷移のUPDATEはこの値を照合条件に使っていなかった。claim時のUPDATE(.is("stripe_hold_status", null))は「最初の1本だけ勝たせる」ための排他制御であって、「以後どのPIのイベントを状態遷移として受け付けるか」の照合ではない。 2つの役割を同じ1本のUPDATEパターンで済ませようとした結果、claim後に届く負けたPI側のイベントを弾く手段が残っていなかった。

直す

succeededcanceled のUPDATEに stripe_payment_intent_id の一致条件を追加し、採用中のPI以外からのイベントでは行がヒットしないようにした(74e2c34a時点)。

// payment_intent.succeeded
if (pi.metadata?.kind === "hold") {
  await admin
    .from("contracts")
    .update({ stripe_hold_status: "captured" })
    .eq("id", contractId)
    .eq("stripe_hold_status", "authorized")
    .eq("stripe_payment_intent_id", pi.id);
}
// payment_intent.canceled
const { data: released } = await admin
  .from("contracts")
  .update({ stripe_hold_status: "released" })
  .eq("id", contractId)
  .eq("stripe_hold_status", "authorized")
  .eq("stripe_payment_intent_id", pi.id)
  .select("id")
  .maybeSingle();

あわせて、checkout.session.completed のclaim負け側に、それまで無かった else 分岐を追加した。同じPIからの再配信(webhookの重複配信)は無音のままにし、別PIだった場合だけ「2タブで2本完了した」と判断して負けた与信をbest-effortで即キャンセルし、Slackへ可視化する。

if (held) {
  // 勝者側の処理
} else {
  const { data: cur } = await admin
    .from("contracts")
    .select("stripe_payment_intent_id")
    .eq("id", contractId)
    .maybeSingle();
  const winner = cur?.stripe_payment_intent_id ?? null;
  if (paymentIntentId && winner !== paymentIntentId) {
    try {
      await stripe.paymentIntents.cancel(paymentIntentId);
    } catch {
      /* 既に失効/取消済みなら Stripe 側で自然解放される */
    }
    await notifySlack(
      `🔴 *仮押さえの二重与信を検出* 2本目の与信を解放しました(採用中: ${winner} / 解放: ${paymentIntentId})`
    );
  }
}

これで「同じPIの再配信」と「別PIによる二重与信」を区別したうえで、後者だけ即座に解放する経路ができた。7日間の自然失効を待たず、顧客のカードから拘束を早く外せる。

再発防止

この経路は本番の顧客には一度も届いていない。実装から26分後、独立レビュー担当(別エージェント)の REQUEST_CHANGES で指摘され、本番デプロイより前に修正が入っている。テストや静的検査を新設したという記録は根拠に無く、今回この不具合を止めたのはコードそのものの仕組みではなく、デプロイ前にもう一段レビューを挟んだ運用だった。

「claimの排他制御」と「状態遷移イベントの照合」という2つの異なる役割を同じUPDATE文のWHERE句に混ぜて済ませようとすると、片方の条件だけでは想定していない行まで拾ってしまう。.eq("id", contractId).eq("stripe_hold_status", "authorized") のような書き方をする箇所では、その行を一意に選んでいるつもりでも、実際に一意なのは「今authorizedな契約」であって「今回のイベントが指すPaymentIntent」ではないことを、UPDATEを書くたびに区別する必要がある。

よくある質問

Q1なぜ同じ契約に2本のPaymentIntentができるのですか?

カード仮押さえのCheckoutを顧客が2タブで開いて両方とも完了させると、Stripe側では別々のPaymentIntentとして正常に処理される。契約行への記録はDBのUPDATEでNULLのときだけ許す形でclaimしていたため、先に届いた1本だけが記録され、もう1本は「負けたPI」として残った。

Q2負けたPIを放置するとどうなりますか?

負けたPIはStripe側でrequires_captureのまま残り、最大7日はカードに与信枠が確保され続ける。顧客からは使っていない与信が7日間拘束される形になる。

Q3PI id照合が無いと具体的に何が起きますか?

succeeded/canceledのUPDATEがcontracts.idとstripe_hold_status='authorized'だけで対象行を絞り込んでいたため、負けたPIに対するイベントでも同じ行がヒットする。負けたPIが自動失効してcanceledイベントが届くと、まだ生きている勝者側の与信までreleasedに書き換わっていた。

Q4本番の顧客に影響しましたか?

していない。この経路は実装から26分後、独立レビュー担当(別エージェント)のREQUEST_CHANGESで指摘され、本番デプロイより前に修正されている。

確認した環境

  • Next.js 16.2.7 / stripe ^22.3.1 / @supabase/supabase-js ^2.106.2
  • 2026-07-18 に実装・同日26分後の独立レビューで発見・修正(本番デプロイ前のため顧客影響なし)

この記事の根拠

  • TypeScriptファイル 35〜82行目コミット 608ee58
  • TypeScriptファイル 124〜168行目コミット 608ee58
  • TypeScriptファイル 36〜119行目コミット 74e2c34
  • TypeScriptファイル 161〜217行目コミット 74e2c34

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。