Rebounder Tech Blog

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

Stripe Checkout amount_unsetで「7日で自動切替」が永久に発動しない

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

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

結論

日次フォールバックの対象条件はamount != nullを要求しており、金額未設定の契約は署名から何日たっても請求書レールへ切り替わらず、カード支払い窓が永久に閉じなかった。

結論

署名時に「カード支払い窓を開いて7日未払いなら請求書レールへ自動切替する」と通知する分岐は、金額が未設定の契約でも成立していた。しかし日次フォールバックが対象を絞り込む条件関数はamount != nullを必須にしていたため、金額未設定の契約は毎日その条件で弾かれ続け、通知どおりの自動切替が一度も起きなかった。

症状

このシステムでは、契約に署名が入ると自動請求の分岐が動く。payment_methodがcard(確定)かNULL(未確定・カード優先)の契約は、署名時点では請求書を発行せず、/pのカードCTAで支払える「窓」を開く。窓が開いたときの通知文言はこうなっていた(修正前・e548ef59時点)。

// カード支払窓(PR-P3): 'card'(確定)と NULL(未確定=カード優先。スポンサー・団体傘下を除く)は
// 署名時に請求書を自動発行せず、/p のカード CTA で支払える窓を作る(Checkout→webhook で入金済)。
// NULL は CARD_INVOICE_FALLBACK_DAYS 日未払いで日次 cron(invoiceStaleCardContracts)が
// 請求書レールへ自動切替。is_enabled=OFF の間はカード導線が無いので従来どおり即請求(段階有効化)。
if (
  ct != null &&
  opensCardPaymentWindow(ct) &&
  (await isStripeEnabled())
) {
  await notifySlack(
    ct.payment_method === "card"
      ? `💳 署名済(${source}): カード決済待ちのため請求書は発行しません(カード確定) ${link}`
      : `💳 署名済(${source}): カード決済待ちのため請求書を保留(${CARD_INVOICE_FALLBACK_DAYS}日未払いで請求書へ自動切替) ${link}`
  );
  return;
}

if (ct?.amount == null) {
  await notifySlack(
    `⚠️ 署名済だが金額未設定のため自動請求をスキップ→金額を設定して手動で請求してください ${slackLink(
      "/admin/contracts",
      "契約を開く"
    )}`
  );
  return;
}

opensCardPaymentWindow(ct)はpayment_methodとcompany_idだけを見ており、金額を見ない。そのため金額未設定の契約でもこの分岐に先に入り、「7日未払いで請求書へ自動切替」という通知が出る。金額未設定の分岐は、その次にしか置かれていなかった。

原因

「7日で自動切替」を実行するのは日次cronのinvoiceStaleCardContractsで、対象行を絞り込む述語は別ファイルに切り出されている。

/**
 * フォールバック対象か(署名時スキップと対になる条件・両方をこの述語に依存させる)。
 * - payment_method NULL のみ('card'=カード確定は請求書に切り替えない・'invoice'=そもそもスキップされない)
 * - **hold(仮押さえ)は対象外**(2026-07-25 監査②): booking の 'card' 強制が単層防波堤だった。
 *   NULL-hold の行が万一存在しても「審査確定まで請求しない」を請求書レール側でも守る。
 * - 団体傘下(billed_to が他社=集約請求)は署名時に即請求されるので対象外
 * - 金額未設定は請求書を出せない(署名時と同じく手動対応)
 * - signed_date が cutoff(today - N日)以前
 */
export function isCardInvoiceFallbackTarget(
  c: CardFallbackContract,
  cutoff: string
): boolean {
  return (
    c.payment_method == null &&
    c.payment_capture !== "hold" &&
    c.amount != null &&
    !!c.company_id &&
    (!c.billed_to_company_id || c.billed_to_company_id === c.company_id) &&
    !!c.signed_date &&
    c.signed_date <= cutoff
  );
}

c.amount != nullが対象条件そのものに入っている。コメントにあるとおり「金額未設定は請求書を出せない」という判断自体は正しい。しかしこれは、署名時に「7日で自動切替する」と約束した窓と、日次cronが「本当に切り替える対象」の集合が最初から一致していなかったことを意味する。金額未設定の契約は毎日このcronの対象から外れ続け、催促も請求書発行も一度も走らない。

さらに、金額未設定の契約はカードで支払うことによる自己解決もできなかった。カードCheckoutのセッション作成はこう書かれている。

const gross = contractGrossAmount(ct.amount as number, Boolean(ct.tax_included));
if (gross == null) return { ok: false, error: "amount_unset" };

ct.amountがnullならcontractGrossAmountはnullを返し、Checkoutセッション自体が作られない。つまり金額未設定の契約は、署名時の自動請求・日次フォールバック・カードCheckoutのどの経路からも進めない状態のまま、「7日で自動切替」という通知だけが独り歩きしていた。

直す

フォールバック側の対象条件(amount != null)は変えていない。直したのは署名時の分岐の順序で、金額未設定のチェックをカード支払い窓の判定より先に出した(修正後・fa65d1ba時点)。

// 金額未設定は**どのレールでも自動処理できない**(カード Checkout も amount_unset で失敗・
// 7日フォールバックも金額なしでは請求書を出せない)ため、カード支払窓の判定より先に手動対応へ
// 逃がす(Reviewer P2-1: 窓分岐を先にすると「自動切替する」と通知して実際は何も起きない)。
if (ct?.amount == null) {
  await notifySlack(
    `⚠️ 署名済だが金額未設定のため自動請求をスキップ→金額を設定して手動で請求してください ${slackLink(
      "/admin/contracts",
      "契約を開く"
    )}`
  );
  return;
}

// カード支払窓(PR-P3): 'card'(確定)と NULL(未確定=カード優先。スポンサー・団体傘下を除く)は
// 署名時に請求書を自動発行せず、/p のカード CTA で支払える窓を作る(Checkout→webhook で入金済)。
// NULL は CARD_INVOICE_FALLBACK_DAYS 日未払いで日次 cron(invoiceStaleCardContracts)が
// 請求書レールへ自動切替。is_enabled=OFF の間はカード導線が無いので従来どおり即請求(段階有効化)。
if (opensCardPaymentWindow(ct) && (await isStripeEnabled())) {
  await notifySlack(
    ct.payment_method === "card"
      ? `💳 署名済(${source}): カード決済待ちのため請求書は発行しません(カード確定) ${link}`
      : `💳 署名済(${source}): カード決済待ちのため請求書を保留(${CARD_INVOICE_FALLBACK_DAYS}日未払いで請求書へ自動切替) ${link}`
  );
  return;
}

これで金額未設定の契約は「7日で自動切替」の窓に入ること自体がなくなり、署名した時点で手動対応のSlack通知だけが出る。カード支払い窓を開く条件と、日次cronが実際に処理できる対象条件を、どちらも「金額が設定されている契約だけ」に揃えたことになる。

再発防止

「窓を開く条件」と「窓を閉じる条件(対象を絞り込む述語)」が別々の場所に書かれていると、片方だけを見て安全そうに見える分岐を足しても、もう片方の条件と食い違っていることに気づきにくい。今回はopensCardPaymentWindowが金額を見ておらず、isCardInvoiceFallbackTargetは金額を必須にしていたという非対称が、コメント上の「7日で自動切替」という説明とコード上の実際の挙動を分岐させていた。窓を開く条件を書き足すときは、対になる「閉じる条件」の述語を必ず横に開いて、両方の集合が一致するかを確認する必要がある。

よくある質問

Q1「7日で自動切替」の通知はどこから出ていましたか?

署名処理(contract-signed.ts)がカード支払い窓を開く分岐に入ったときに、Slackへ固定文言で通知していた。分岐に入る条件を満たせば、金額が未設定でも同じ通知が出ていた。

Q2なぜ金額未設定だけ窓が閉じなかったのですか?

日次cronが参照する対象条件関数isCardInvoiceFallbackTargetが、amount != nullを必須条件にしていたため。金額未設定の契約はこの条件で毎日弾かれ続け、催促や請求書発行が一度も走らなかった。

Q3顧客はカードで支払うこともできなかったのですか?

できなかった。カードCheckout側もcontractGrossAmountがnullを返す時点でamount_unsetエラーを返して停止するため、金額未設定の契約は自動請求・日次フォールバック・カード決済のどの経路からも進めなかった。

Q4直し方はフォールバック条件を変えることではないのですか?

変えていない。フォールバック側のamount != null条件はそのまま残し、署名時の分岐順序を変えて金額未設定の契約を最初からカード支払い窓に入れないようにした。

Q5本番の顧客に影響しましたか?

根拠にある範囲では、この機能は同じ日(07-12)に実装・本番適用され、同じコミットでReviewer指摘として修正されている。実際に金額未設定×NULLの契約が本番に滞留していたかどうかは根拠に記載が無く、ここでは判断しない。

確認した環境

  • Next.js 16.2.7 / stripe ^22.3.1 / @supabase/supabase-js ^2.106.2
  • 2026-07-12 に実装・同日Reviewer指摘(P2-1)で修正

この記事の根拠

  • TypeScriptファイル 133〜165行目コミット e548ef5
  • TypeScriptファイル 133〜164行目コミット fa65d1b
  • TypeScriptファイル 22〜44行目コミット 7eb2911
  • TypeScriptファイル 15〜45行目コミット fa65d1b
  • TypeScriptファイル 50〜65行目コミット 8e1f480

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