2タブで完了した2本のPaymentIntent、負けた側のwebhookが与信を巻き戻す
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
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_status が null の間だけ更新が通るため、同じ契約に対して2本目の checkout.session.completed が届いても2本目はclaimに負け、held が null のまま静かに終わる。ここまでは意図どおりだが、負けた側のPaymentIntent自体はStripe上でrequires_captureのまま残り続ける。 何も解放しないため、顧客のカードには最大7日、使われない与信枠が確保されたままになる。
さらに、payment_intent.succeeded と payment_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.id と stripe_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側のイベントを弾く手段が残っていなかった。
直す
succeeded と canceled の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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。