決済成功後のDB反映失敗をカード失敗と誤認し、二重請求になりかけた
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
PaymentIntent作成の成否とDB反映(markContractPaid)の成否を同じtry/catchで扱うと、課金成立後の一過性DB書込エラーがcatchでカード失敗と誤診断され、課金済みのカードに対してさらに請求書フォールバックを発行する二重請求になる。
結論
PaymentIntent作成の成否とDB反映(markContractPaid)の成否を同じtry/catchで扱うと、課金成立後の一過性DB書込エラーがcatchでカード失敗と誤診断され、課金済みのカードに対してさらに請求書フォールバックを発行する二重請求になる。 契約の自動更新をオフセッションでカード課金する処理で、この構造がそのまま残っていた。
何が起きうる状態だったか
自動更新の課金処理は、保存済みのカードでオフセッション課金(stripe.paymentIntents.create)を試み、成功したらmarkContractPaidでDBの入金状態を更新する。カードが使えない・SCA認証が必要・課金自体が失敗した場合は、契約を振込請求書のレールへフォールバックさせる。
この2つの処理が、同じtryブロックの中に入っていた。
try {
const pi = await stripe.paymentIntents.create(/* ... */);
if (pi.status === "succeeded") {
await markContractPaid(admin, {
contractId,
paymentIntentId: pi.id,
card: { paymentMethodId: pmId, last4: null, brand: null },
});
await notifySlack(`💳 更新をカードで自動課金 ...`);
return { ok: true, charged: true, paymentIntentId: pi.id };
}
// requires_action 等(SCA)は off_session では完了できない → フォールバック。
const r = await fallbackToInvoice(admin, contractId);
/* ... */
return { ok: true, charged: false, fellBack: true, reason: `pi_${pi.status}` };
} catch (e) {
// StripeCardError(authentication_required / card_declined / expired_card 等)→ フォールバック。
const code = (e as { code?: string })?.code ?? "card_error";
const r = await fallbackToInvoice(admin, contractId);
/* ... */
return { ok: true, charged: false, fellBack: true, reason: String(code) };
}
catchのコメントは「StripeCardError → フォールバック」とだけ書かれている。つまりこのcatchは、ブロック内のどの行が例外を投げても実行される場所であるにもかかわらず、実装者の意識の上では「paymentIntents.createが投げたStripeのカードエラーだけを受ける場所」として扱われていた。
原因
直前のコミットで、markContractPaidとfallbackToInvoiceはDB書込エラー時に例外を投げるよう変更されていた。一過性の書込エラーを「既に処理済み」と誤認して処理を止めないための変更で、それ自体は妥当だった。
しかしchargeContractRenewal側のtryブロックは、この変更前の前提のまま残っていた。ブロックの中には次の3種類の処理が同居していた。
stripe.paymentIntents.create— カードへの課金(失敗=未課金)markContractPaid— 課金成立後のDB反映(失敗=課金済みだがDB未反映)fallbackToInvoice(SCA分岐側) — 請求書フォールバックの発行
この3つは「失敗したときに何が起きているか」がまったく違う。1の失敗はカードが課金されていないことを意味するが、2の失敗はカードは既に課金されていることを意味する。ところがcatchは1の意味だけを前提に書かれていたため、2が投げた例外を1が投げた例外と区別できず、「カード未課金」と誤診断してフォールバックの請求書を発行してしまう。カード課金と振込請求書が両方成立する二重請求は、ここから生まれる。
直し方
paymentIntents.createの呼び出しだけをtryで囲み、成功後のmarkContractPaidはtryの外に出した。
let pi: Awaited<ReturnType<NonNullable<typeof stripe>["paymentIntents"]["create"]>>;
try {
pi = await stripe.paymentIntents.create(/* ... */);
} catch (e) {
// StripeCardError(authentication_required / card_declined / expired_card 等)=カードは課金されていない → フォールバック。
const code = (e as { code?: string })?.code ?? "card_error";
return issueRenewalFallback(admin, contractId, String(code), /* ... */);
}
if (pi.status === "succeeded") {
// カードは課金成立。ここから先の DB 反映が失敗しても請求書レールへは絶対に倒さない
// (カード課金済み+振込請求=二重請求になる)。markContractPaid は書込エラーで throw するが、
// payment_intent.succeeded webhook が同じ契約を入金済へ自己回復するので best-effort とし、
// 黙って握らず Slack で可視化する。
try {
await markContractPaid(admin, { contractId, paymentIntentId: pi.id, /* ... */ });
} catch (persistErr) {
console.error("[renewal] 課金成立後の入金反映に失敗(webhook で回復見込み):", persistErr);
await notifySlack(`⚠️ 更新:カード課金は成立(¥${gross})だが入金反映に失敗→webhook で自己回復見込み・要確認 ...`);
}
await notifySlack(`💳 更新をカードで自動課金 ...`);
return { ok: true, charged: true, paymentIntentId: pi.id };
}
// requires_action 等(SCA)は off_session では完了できない=カード未課金 → フォールバック。
return issueRenewalFallback(admin, contractId, `pi_${pi.status}`, /* ... */);
markContractPaidの失敗はもう請求書レールへ倒さない。カードは既に課金済みなので、二重請求という最悪の結果を避けることを優先し、DB反映の失敗はSlack通知で可視化したうえでpayment_intent.succeededのwebhookによる自己回復に委ねるbest-effort扱いにした。
あわせて、3箇所に散っていた請求書フォールバックの発行処理をissueRenewalFallbackという1つの関数に集約した。fallbackToInvoice自体もDB書込エラーでthrowしうるため、この関数の中でその例外を握り、呼び出し元へは常にRenewalResultという戻り値の型で返すよう正規化した。呼び出し側のどこにも、例外の型を見て課金状態を推測させるコードが残らない構造にしている。
再発防止
修正コミットには回帰テストを追加し、カード課金成立後にmarkContractPaidが失敗しても振込請求書が発行されないことを固定した。
この事故の構造的な原因は、失敗の意味が異なる複数の非同期処理を1つのtryブロックにまとめ、そのcatchを「最初の処理が失敗したときだけの場所」だと思い込んで書いたことにある。tryブロックはコード上の見た目では境界がわかりにくいが、実行時には中の全行が同じcatchに流れ込む。「このtryは何の失敗を捕まえる場所か」を1つに絞れないなら、成功後に取り返しがつかない処理(今回で言えばカード課金)を含むtryは、それより後ろの処理と分離したほうがいい。
よくある質問
Q1なぜカード課金の成功後にDB書込が失敗すると誤診断になるのですか?
PaymentIntent作成とその後のmarkContractPaid(入金反映)を同じtryブロックの中に置いていたためです。markContractPaidが一過性のDB書込エラーでthrowすると、その例外はPaymentIntent作成の失敗時と同じcatchに落ち、catchは『カードが課金されていない』前提でしか書かれていませんでした。
Q2誤診断の結果、実際に何が起きるのですか?
catchはStripeCardError用のフォールバック処理として、契約を振込請求書レールへ倒します。しかしこのケースではカードの課金自体は既に成立しているため、カード課金と振込請求書の両方が発生する二重請求になります。
Q3この誤診断はどう直しましたか?
PaymentIntent作成だけをtryで囲み、成功後のmarkContractPaidは別のtry/catchに分離しました。DB反映が失敗しても請求書レールへは倒さず、Slack通知で可視化したうえでStripeのwebhookによる自己回復に委ねるbest-effort扱いにしています。
Q4このバグは本番で実際に二重請求を起こしたのですか?
起きていません。該当の自動更新課金の経路は当時まだ有効化前(dormant)で、独立レビューによって有効化前に検出・修正されました。
確認した環境
- stripe ^22.3.1 / Next.js 16.2.7
- 2026-07-13 に独立レビューで検出・同日中に修正(対象経路は有効化前のdormant状態)
この記事の根拠
- TypeScriptファイル 94〜147行目コミット 839a21a
- TypeScriptファイル 129〜188行目コミット ad450a9
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。