Rebounder Tech Blog

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

StripeのfallbackToInvoiceがメール送信失敗を送付済みと誤って数えた

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

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

結論

Stripeの請求書フォールバックはissueInvoiceAndEmailの戻り値(メールを送れたか)を捨ててissued:trueだけを返し、呼び出し元はメール未送信でも『送付済み』と同じSlack文面を出し続けていた。

結論から

Stripeの自動更新課金は、保存カードへのオフセッション課金が既定のレールだ。カードが未登録・SCA要求・カード拒否のいずれかで課金できないときは、従来の請求書レール(fallbackToInvoice)へ自動でフォールバックし、顧客は必ず振込で支払えるようにしてある。

ところがこのフォールバック関数は、**請求書そのものを発行できたかと、メールを送れたかを区別していなかった。**内部で呼んでいるissueInvoiceAndEmailはメール送信の成否をbooleanで返すのに、fallbackToInvoiceはその戻り値を受け取らずに捨てていた。結果、DBの「未請求→請求済」クレームに成功した時点で必ずissued: trueを返し、Slack通知も自動更新の集計も、メールが実際に届いたかとは無関係に「送付済み」として扱われていた。

戻り値を1つに削った設計

修正前のfallbackToInvoiceはこう書かれていた。

export async function fallbackToInvoice(
  admin: SupabaseClient,
  contractId: string
): Promise<{ issued: boolean }> {
  // ...未請求→請求済のクレーム(条件付きUPDATE)...
  if (!claimed) return { issued: false }; // 既に請求済/入金済=二重発行しない

  await issueInvoiceAndEmail(admin, contractId, null);
  return { issued: true };
}

await issueInvoiceAndEmail(...)の行に代入先が無い。この関数は「送付先未登録」「請求書リンクの作成失敗」「メール送信失敗」のいずれでも例外を投げずbooleanを返す設計だが、その結果を誰も見ていなかった。claimed(DBのクレーム)さえ取れれば、あとは何が起きてもissued: trueが返る。

呼び出し元のissueRenewalFallback(src/lib/stripe/renewal.ts)は、このissuedだけを見てSlackへ通知していた。

await notifySlack(`${notifyPrefix}${issued ? "" : "(既に請求済/入金済)"} ${link}`);

issuedがtrueなら無条件で「送付済み」と同じ文面が飛ぶ。メールが実際に届いたかどうかは、この経路のどこにも登場しない。

さらにcontract-renewal.tsのinvoiceSuccessorは、カード課金が失敗してフォールバックした件数をresult.invoiced(請求発行済みの集計)にそのまま積んでいた。DBのクレームが取れた=請求書を発行できた、という前提で数えていたため、メールが届いていない契約まで「請求済み」の集計に混ざっていた。

届いていないのに、誰も気づかない

この状態で何が起きるかは単純だ。送付先メールアドレスが未登録だったり、請求書PDFへのリンク作成に失敗したり、メール送信そのものが失敗したりしても、

  • DBのpayment_statusは請求済のまま
  • Slackには「請求書を送付しました」と同じ文面が流れる
  • 自動更新の集計にも「請求発行1件」として数えられる

という3つが揃って成立する。顧客には何も届いていないのに、社内のどの指標を見ても「処理済み」にしか見えない。本当に気づけるのは、顧客から「請求書が来ていない」と問い合わせが来たときだけになる。

修正: sentを運び切る

修正はfallbackToInvoiceの戻り値を{ issued: boolean; sent: boolean }に広げ、issueInvoiceAndEmailの戻り値をそのままsentとして運ぶだけだった。

export async function fallbackToInvoice(
  admin: SupabaseClient,
  contractId: string
): Promise<{ issued: boolean; sent: boolean }> {
  // ...
  if (!claimed) return { issued: false, sent: false };

  // sent=false(送付先未登録・リンク作成失敗・送信失敗)でも請求済のクレームは残る。
  // 呼び出し元が「送付済」と誤報しないよう、送れたかどうかをそのまま返す。
  const sent = await issueInvoiceAndEmail(admin, contractId, null);
  return { issued: true, sent };
}

呼び出し元のissueRenewalFallbackも、issuedとsentの両方を見てSlackの文面を分けるようになった。

// 請求済にはクレーム済みでもメールが出ていなければ、送付済と誤報せず手動確認へ回す。
const status = !issued ? "(既に請求済/入金済)" : sent ? "" : "(メール未送信→手動確認)";
await notifySlack(`${notifyPrefix}${status} ${link}`);
return { ok: true, charged: false, fellBack: true, sent, reason };

そしてinvoiceSuccessor側の集計も、sentがtrueのときだけresult.invoicedを増やすように変えている。DBのクレームが取れたことと、請求発行として数えてよいことは別の条件になった。

再発防止として足したこと

この修正では、戻り値を1段広げて呼び出し元まで運ぶという型レベルの変更だけで、fallbackToInvoice・issueRenewalFallback・invoiceSuccessorの3箇所すべてが「DBのクレームが取れた」と「メールが届いた」を取り違えなくなった。振込レール(請求書フォールバックではない、もともとの請求書発行経路)は元からsentを見て集計していたため、今回はカード→請求書フォールバックの経路だけがその基準から外れていた形になる。同じ「送れたかどうか」を見る基準を、2つのレールで揃えたのが今回の修正の範囲だ。

よくある質問

Q1fallbackToInvoiceの戻り値は何が間違っていましたか?

修正前のfallbackToInvoiceは{ issued: boolean }だけを返し、内部で呼んでいるissueInvoiceAndEmail(メール送信)の戻り値を捨てていました。DBの請求済みクレームに成功した時点で必ずissued:trueを返すため、送付先未登録・リンク作成失敗・送信失敗のどれが起きても、呼び出し元からは『請求書を発行できた』としか見えませんでした。

Q2この問題はどうやって直しましたか?

fallbackToInvoiceの戻り値を{ issued: boolean; sent: boolean }に変え、issueInvoiceAndEmailの戻り値をsentとしてそのまま返すようにしました。呼び出し元のissueRenewalFallbackはsentがfalseのときSlack通知に『(メール未送信→手動確認)』を付け、invoiceSuccessor側の集計(RenewalResult.invoiced)もsentがtrueのものだけを数えるように変更しています。

Q3請求書フォールバックとはどういう処理ですか?

自動更新の課金は保存カードへのオフセッション課金が既定ですが、カード未登録・SCA要求・カード拒否のときは従来の請求書レールへ自動フォールバックします。顧客が必ず振込で支払えるようにするための仕組みで、フォールバック先でも『未請求→請求済』を条件付きUPDATEでクレームしてから請求書を発行・送付します。

確認した環境

  • stripe ^22.3.1 / @supabase/supabase-js ^2.106.2(kimiteras-portal)
  • 2026-09-30 本番で修正(コミット677e267)

この記事の根拠

  • TypeScriptファイル 120〜138行目コミット 7e7ea38
  • TypeScriptファイル 120〜140行目コミット 677e267
  • TypeScriptファイル 1〜44行目コミット 677e267
  • TypeScriptファイル 58〜101行目コミット 677e267

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