Rebounder Tech Blog

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

Stripe Checkoutの二重セッションで、負けたPaymentIntentがholdを巻き戻す

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

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

結論

StripeのPaymentIntent.canceled webhookはPI idを照合しないUPDATEのWHERE句を通ると、2本のCheckoutで負けた側のキャンセルイベントが、採用中(勝った)与信のholdをreleasedへ巻き戻してしまう。

結論

StripeのPaymentIntent.canceled webhookはPI idを照合しないUPDATEのWHERE句を通ると、2本のCheckoutで負けた側のキャンセルイベントが、採用中(勝った)与信のholdをreleasedへ巻き戻してしまう。

症状

このシステムには、カードを即課金せず「仮押さえ(hold)」で与信だけ確保し、審査OKでcaptureする契約レールがある。checkout.session.completedのhold分岐は、contracts行のstripe_hold_statusがまだNULLのときだけUPDATEを成立させる「claim」として書かれていた(修正前・608ee583時点)。

// 与信確保を claim(NULL のときだけ)=再配信・二重セッションでも1回に収束。
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) {
  await notifySlack(/* 🔒 仮押さえ通知 */);
  await logAuditSystem({ /* ... */ });
}
return { contractId };

顧客が2タブでCheckoutを開いて両方完了させると、Stripe側には独立した2本のCheckout SessionとPaymentIntent(PI_A・PI_B)が生まれる。先にwebhookが届いた方(例: PI_A)だけが.is("stripe_hold_status", null)のUPDATEを通って「勝者」になり、stripe_payment_intent_idにPI_Aが記録される。後から届いたPI_Bのイベントは対象行が既にNULLでないためUPDATEが0行で終わり、heldはnullになる。このif (held)にはelse分岐が無く、claimに負けたPI_Bの与信は何も処理されないまま残る。

原因

claimに負けたPI_Bの与信は、Stripe側で最大7日の期限切れを待つしかない。期限が切れるとpayment_intent.canceledが発火する。この取消イベントを処理していたハンドラは次のように書かれていた(修正前・608ee583時点)。

case "payment_intent.canceled": {
  // 仮押さえの与信解放(審査却下の同期パス/Stripe 側の与信期限切れ=約7日 の backstop)。
  const pi = event.data.object as Stripe.PaymentIntent;
  const contractId = pi.metadata?.contract_id ?? null;
  if (!contractId || pi.metadata?.kind !== "hold") return { contractId };
  const { data: released } = await admin
    .from("contracts")
    .update({ stripe_hold_status: "released" })
    .eq("id", contractId)
    .eq("stripe_hold_status", "authorized")
    .select("id")
    .maybeSingle();
  if (released) {
    await notifySlack(/* 🔓 解放通知 */);
    await logAuditSystem({ /* ... */ });
  }
  return { contractId };
}

WHERE句はcontractIdとstripe_hold_status = 'authorized'でしか絞り込んでいない。**どのPIのイベントかは見ていない。**PI_Bのcanceledが届いた時点で、契約は既にPI_Aの与信でauthorizedのまま生きている。WHERE句の条件はこの行に一致するので、UPDATEはPI_Bのキャンセルを理由にPI_Aの与信をreleasedへ書き換えてしまう。

payment_intent.succeeded側も同じ形で書かれていた(修正前・608ee583時点)。

case "payment_intent.succeeded": {
  const pi = event.data.object as Stripe.PaymentIntent;
  const contractId = pi.metadata?.contract_id ?? null;
  if (!contractId) return { contractId: null };
  const card = extractCard(pi.payment_method as Stripe.PaymentMethod | string | null);
  await markContractPaid(admin, { contractId, paymentIntentId: pi.id, card });
  // 仮押さえの capture 完了を hold 状態にも反映(authorized のときだけ・冪等)。
  if (pi.metadata?.kind === "hold") {
    await admin
      .from("contracts")
      .update({ stripe_hold_status: "captured" })
      .eq("id", contractId)
      .eq("stripe_hold_status", "authorized");
  }
  return { contractId };
}

ここもstripe_hold_status = 'authorized'だけが絞り込み条件で、PI idの照合が無い。審査が確定してPI_Aをcaptureしようとしている最中にPI_Bのsucceeded(想定外だが到達しうる)が割り込めば、同じ経路でPI_Aの状態を書き換えられる。

「claimは1本だけに収束させる」という設計そのものは正しい。だがclaimの結果(stripe_payment_intent_id)を、後続のstatus遷移のUPDATEが一度も参照していなかったために、claimで負けたPIのイベントが、claimで勝ったPIの行を動かせる経路が残っていた。

直す

直したのは2か所。まずclaim負けの分岐を追加し、負けた与信をその場でbest-effortに解放して可視化する(修正後・74e2c34a時点)。

if (held) {
  await notifySlack(/* 🔒 仮押さえ通知 */);
  await logAuditSystem({ /* ... */ });
} else {
  // claim 負け=この契約には既に与信/決着がある。同一 PI(webhook 再配信)は無音で正常。
  // 別 PI(顧客が2タブで Checkout を2本完了)は放置すると最大7日カードに二重の資金拘束が
  // 残るため、負けた与信を best-effort で即解放し可視化する(即時課金分岐の「🔴 二重支払の
  // 可能性」と同じ規律)。cancel が発火させる payment_intent.canceled は PI id 照合により
  // 採用中の与信へは波及しない。
  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})`
    );
    await logAuditSystem({ /* ... */ });
  }
}

次に、succeededとcanceled両方のWHERE句にPI idの照合を追加した(修正後・74e2c34a時点)。

// 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);
}
// 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();
if (released) {
  await notifySlack(/* 🔓 解放通知(期限切れ backstop) */);
  await logAuditSystem({ /* ... */ });
  await sendHoldReleasedEmail(admin, contractId, {
    variant: pi.cancellation_reason === "automatic" ? "expired" : "rejected",
  });
}

これでpayment_intent.canceled・payment_intent.succeededのどちらも、自分が指すPI idが、契約行に記録されている「勝者のPI id」と一致するときだけ状態を動かす。負けた側のイベントが勝者の行に触れる経路がなくなった。あわせて、見送り時に顧客へ結果メールを送る分岐(sendHoldReleasedEmail)も同じコミットで追加されている。

再発防止

このバグは「状態遷移の条件」と「誰がその遷移を引き起こしてよいか」を別々に扱っていたことから生まれている。stripe_hold_status = 'authorized'という条件は契約の状態を見ているが、releasedやcapturedに倒す権限がどのPIにあるかは見ていなかった。claim自体はstripe_payment_intent_idという形で「誰が勝者か」を記録していたのに、その後のUPDATEが一度もそれを参照していなければ、記録は存在するだけで効力を持たない。

同一の集約(この場合は契約の与信状態)に対して複数のアクター(複数のPI)が競合しうる設計では、状態だけを条件にしたUPDATEは「今どちらが正しいアクターか」を区別できない。claimで勝者を記録する行を追加するときは、その後続くすべての状態遷移のUPDATEに、同じ勝者idの照合を対で入れる必要がある。

よくある質問

Q1なぜ同一契約に2本のPaymentIntentが発生するのですか?

顧客が2タブでCheckoutを開いて両方完了させると、Stripe側では独立した2本のCheckout SessionとPaymentIntentが作られる。claim処理はcontracts行のstripe_hold_statusがNULLのときだけUPDATEが成立する設計で、先に届いたイベントだけが仮押さえを獲得し、後から届いた側はUPDATEが0行でheldがnullになる。

Q2負けた側のPaymentIntentは、claimに負けた時点でどうなっていましたか?

修正前はclaim負けの分岐が無く、何もしていなかった。負けた側のカード与信はStripe側でそのまま残り、最大7日の期限切れを待つしかなかった。

Q37日後に何が起きたのですか?

期限切れでStripeがpayment_intent.canceledを発火する。修正前のハンドラはこのイベントをcontractIdとstripe_hold_status='authorized'だけで絞り込んでUPDATEしており、PI idを見ていなかった。その時点で契約の勝者側与信はauthorizedのまま生きているため、負けた側の取消イベントがその行を拾ってreleasedへ書き換えてしまう。

Q4直し方はWHERE句を直すだけですか?

WHERE句にstripe_payment_intent_idの照合を追加したのが本体だが、それだけではclaimに負けた与信が最大7日間カードに拘束されたまま残る問題が残る。あわせてclaim負けの時点でその場でキャンセルしてSlackに🔴で可視化する分岐も追加された。

Q5本番で実際にこの事故が起きたのですか?

根拠はカード仮押さえレールへの独立Reviewer(REQUEST_CHANGES)のP2指摘対応コミットであり、指摘自体が事故の経路を指している。実際に顧客が2タブで踏んで本番の与信が巻き戻った記録はsourcesには無く、ここでは判断しない。

確認した環境

  • Next.js 16.2.7 / stripe ^22.3.1 / @supabase/supabase-js ^2.106.2
  • 2026-07-18 独立Reviewer(REQUEST_CHANGES)のP2指摘対応コミットで修正

この記事の根拠

  • TypeScriptファイル 55〜82行目コミット 608ee58
  • TypeScriptファイル 124〜140行目コミット 608ee58
  • TypeScriptファイル 142〜168行目コミット 608ee58
  • TypeScriptファイル 81〜117行目コミット 74e2c34
  • TypeScriptファイル 161〜179行目コミット 74e2c34
  • TypeScriptファイル 181〜217行目コミット 74e2c34

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