Stripe Checkout amount_unsetで「7日で自動切替」が永久に発動しない
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
日次フォールバックの対象条件は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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。