Rebounder Tech Blog

Written by the people who actually run these systems in production.

A Post-Charge DB Failure Read as a Card Decline

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

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

結論

Put PaymentIntent creation and markContractPaid in one try/catch and a DB write error after the charge succeeds is misdiagnosed as a card failure, invoicing an already-charged card twice.

The short version

Handle the success or failure of PaymentIntent creation and of the DB write (markContractPaid) in the same try/catch, and a transient DB write error after the charge succeeds is misdiagnosed in catch as a card failure, issuing an invoice fallback against an already-charged card — a double charge. That structure sat unchanged in the routine that charges a contract’s auto-renewal off-session.

The state it was in

The auto-renewal charging routine attempts an off-session charge on a stored card (stripe.paymentIntents.create) and, on success, updates the payment state in the DB with markContractPaid. If the card is unusable, SCA is required, or the charge itself fails, the contract falls back onto the bank-transfer invoice rail.

Both of these lived inside the same try block.

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 etc. (SCA) cannot complete off_session → fall back.
  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 etc.) → fall back.
  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) };
}

The comment on the catch says only “StripeCardError → fall back”. That is, although this catch is where execution goes when any line in the block throws, in the author’s mind it was “the place that receives Stripe card errors thrown by paymentIntents.create”.

Why

An immediately preceding commit had changed markContractPaid and fallbackToInvoice to throw on a DB write error. That change existed to stop a transient write error from being mistaken for “already processed” and halting the routine, and it was sound in itself.

But the try block in chargeContractRenewal remained on the pre-change premise. Three different kinds of work coexisted inside that block.

  1. stripe.paymentIntents.create — charging the card (failure = not charged)
  2. markContractPaid — the DB write after a successful charge (failure = charged but not recorded)
  3. fallbackToInvoice (the SCA branch) — issuing the invoice fallback

What is true when each fails is completely different. A failure of 1 means the card was not charged; a failure of 2 means the card already was. The catch, written on the premise of 1’s meaning alone, cannot tell an exception thrown by 2 from one thrown by 1, so it misdiagnoses “card not charged” and issues the fallback invoice. That is where a double charge — a card charge and a transfer invoice both standing — comes from.

Fixing it

We wrapped only the paymentIntents.create call in try and moved the post-success markContractPaid outside it.

let pi: Awaited<ReturnType<NonNullable<typeof stripe>["paymentIntents"]["create"]>>;
try {
  pi = await stripe.paymentIntents.create(/* ... */);
} catch (e) {
  // StripeCardError (authentication_required / card_declined / expired_card etc.)
  // = the card was NOT charged → fall back.
  const code = (e as { code?: string })?.code ?? "card_error";
  return issueRenewalFallback(admin, contractId, String(code), /* ... */);
}

if (pi.status === "succeeded") {
  // The card charge succeeded. Whatever happens to the DB write from here, never tip onto
  // the invoice rail (card charged + transfer invoice = double charge). markContractPaid
  // throws on a write error, but the payment_intent.succeeded webhook recovers the same
  // contract to paid, so treat it as best-effort — surfaced in Slack, never swallowed silently.
  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 etc. (SCA) cannot complete off_session = card not charged → fall back.
return issueRenewalFallback(admin, contractId, `pi_${pi.status}`, /* ... */);

A failure of markContractPaid no longer tips onto the invoice rail. The card is already charged, so avoiding the worst outcome — a double charge — takes priority: a failed DB write is surfaced with a Slack notification and left as best-effort, recovering through the payment_intent.succeeded webhook.

Alongside this, the invoice-fallback issuance scattered across three places was consolidated into one function, issueRenewalFallback. Since fallbackToInvoice can itself throw on a DB write error, that function catches it internally and normalises the return to always be a RenewalResult. The structure leaves no code anywhere on the calling side that infers the charge state from an exception’s type.

Preventing a repeat

The fix commit added a regression test pinning that no transfer invoice is issued when markContractPaid fails after a successful card charge.

The structural cause of this incident was gathering several async operations whose failures mean different things into one try block, and writing its catch in the belief that it was “the place for when the first operation fails”. A try block’s boundary is hard to see in the code, but at runtime every line inside it flows into the same catch. If you cannot narrow “what failure is this try here to catch” to a single answer, a try containing an operation that cannot be taken back once it succeeds — the card charge, here — is better separated from everything that comes after it.

よくある質問

Q1Why is a DB write failure after a successful charge a misdiagnosis?

Because PaymentIntent creation and the markContractPaid that follows it sat in the same try block. When markContractPaid throws on a transient DB write error, that exception lands in the same catch as a creation failure — and the catch assumed the card was not charged.

Q2What actually happens as a result?

As its fallback for a StripeCardError, the catch tips the contract onto the bank-transfer invoice rail. But in this case the card charge already succeeded, so both a card charge and a transfer invoice occur — a double charge.

Q3How was the misdiagnosis fixed?

We wrapped only the PaymentIntent creation in try, and split the post-success markContractPaid into its own try/catch. A failed DB write no longer tips onto the invoice rail: it is surfaced with a Slack notification and left as best-effort, recovering through Stripe's webhook.

Q4Did this bug actually double-charge anyone in production?

It did not. The auto-renewal charging path in question was still dormant, not yet enabled, and an independent review caught and fixed it before it was turned on.

確認した環境

  • stripe ^22.3.1 / Next.js 16.2.7
  • Found in independent review on 2026-07-13 and fixed the same day (the path was dormant, not yet enabled)

この記事の根拠

  • TypeScriptファイル 94〜147行目コミット 839a21a
  • TypeScriptファイル 129〜188行目コミット ad450a9

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