ステータス不変のunconditional updateがcurrent dateをresetし、deadlineが動く
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
UPDATEに渡すpatchオブジェクトへ日付フィールドを無条件で詰めていると、呼び出し元がpayment_statusを変更せずに再送しただけでも、状態遷移時だけ記録するはずの日付列が今日の日付で上書きされる。
結論
契約の支払いステータスを更新する処理が、UPDATEのpatchオブジェクトへ日付フィールド(invoice_date / paid_date)を無条件で詰めていたため、管理画面で同じステータス値を押し直すだけでも、本来は状態遷移時だけ記録するはずの日付が今日の日付に書き換わっていた。 invoice_dateは入金遅延の督促起点、paid_dateは領収証・凍結帳票の日付根拠として使われる列で、再保存のたびに動くと帳票の日付が実際の遷移日とずれる。直し方は、日付を詰める条件に「変更前の値が今と違う(=実際に遷移が起きた)」という条件を足すこと。
症状
契約の支払いステータスを更新する関数は、まず変更前の値を読み、その後でUPDATEに渡すpatchオブジェクトを組んでいた。
// actions.ts:299-305(修正前)
const prevPayment = before?.payment_status as string | null | undefined;
// 請求日・入金日は JST 基準(reconcilePayment と同様)。UTC だと JST 早朝に前日付になる。
const today = jstToday();
const patch: Record<string, unknown> = { payment_status: payment, payment_status_auto: false };
if (payment === "請求済") patch.invoice_date = today;
if (payment === "入金済") patch.paid_date = today;
この時点ではまだ prevPayment(変更前の値)を取得済みなのに、patch.invoice_date / patch.paid_date を詰める条件には使われていない。payment が "請求済" であれば、それが新規の遷移なのか、すでに "請求済" だった行を再度 "請求済" のまま保存しただけなのかを区別せず、常に今日の日付を詰めていた。
このpatchは、その後の分岐で実際にUPDATEへ渡される。
// actions.ts:311-326(修正前)
if (payment !== prevPayment) {
const base = supabase.from("contracts").update(patch).eq("id", id);
const { data: claimed } = await (prevPayment == null
? base.is("payment_status", null)
: base.eq("payment_status", prevPayment)
)
.select("id")
.maybeSingle();
if (!claimed) {
// 別の操作(webhook・並行タブ)が先に遷移させた。帳票の二重発行を避けて何もしない。
revalidatePath("/admin/contracts");
return;
}
} else {
await supabase.from("contracts").update(patch).eq("id", id);
}
payment !== prevPayment(値が実際に変わる遷移)の場合は条件付きUPDATEでclaimを試み、claimできなければ何もせず戻る設計になっている。ここは二重送信や並行操作への対策が入っている。問題は else 側、つまり payment === prevPayment(管理画面でチェックボックスを押し直すなどして同じ値を再送した場合)で、ここは何の条件も付けずに patch をそのままUPDATEへ渡していた。その patch には、すでに見たとおり無条件で詰められた今日の日付が入っている。
原因
このコードは「状態遷移の競合(二重クリック・並行タブ・webhookとの併走)を防ぐ」という目的のために、変更前の値を基準にした条件付きUPDATEを用意していた。この設計自体は遷移の競合には対応できている。しかし、patch オブジェクトを組む工程と、それを実際にUPDATEへ渡す工程が分離していたため、「日付を詰めるかどうか」の判断が「遷移の競合を防ぐかどうか」の判断より先に、しかも別の条件(payment の値だけ)で確定してしまっていた。prevPayment という、遷移が実際に起きたかどうかを判定できる値はこの時点で既に手元にあったにもかかわらず、日付を詰める条件には使われていなかった。
結果として、payment === prevPayment の経路(同じ値の再保存)に入ると、遷移が起きていないにもかかわらず、遷移時用に用意された日付だけがUPDATEに乗って書き込まれる。
直し方
日付を詰める条件に、変更前の値との比較を追加した。
// actions.ts:301-308(修正後)
// ⚠ 日付列は「遷移時」だけ書く(signed_date と同型)。同値の再保存でも書くと、
// 入金済の行で ✓ を押し直すだけで paid_date(領収証・凍結帳票の日付根拠)が当日に動き、
// 請求済の再保存で invoice_date(入金遅延30日判定・督促の起点)が黙ってリセットされる。
const today = jstToday();
const patch: Record<string, unknown> = { payment_status: payment, payment_status_auto: false };
if (payment === "請求済" && prevPayment !== "請求済") patch.invoice_date = today;
if (payment === "入金済" && prevPayment !== "入金済") patch.paid_date = today;
else 側の分岐(同じ値を再送したときにUPDATEを呼ぶ処理)はそのまま変更していない。patch 自体に日付フィールドが入らなくなったため、else 側でUPDATEが実行されても payment_status や payment_status_auto 以外の列は書き換わらない。遷移の競合を防ぐための条件付きUPDATEの構造には手を加えず、「日付を詰めるかどうか」の判断だけを遷移の有無に揃えた。
再発防止
このコードには「変更前の値」という、遷移が実際に起きたかどうかを判定できる情報が最初から存在していた。バグが起きたのは、その情報を使う判断(UPDATEを実行するかどうか)と、使わない判断(patchに何を詰めるか)が同じ関数の中で別々に書かれていたことによる。UPDATE文を組み立てるとき、「どの値を書くか」を決める条件と「書き込みを実行するかどうか」を決める条件が同じ関数内に複数あるなら、両方が同じ前提(この場合は「実際に遷移したか」)を見ているかを個別に確認する必要がある。片方だけ前提を更新し、もう片方を直し忘れると、こうした静かな不整合が残る。
よくある質問
Q1同じステータス値を再送するだけで日付が動いたのはなぜですか?
patchオブジェクトを作る箇所でpayment === '請求済'ならinvoice_date、payment === '入金済'ならpaid_dateを無条件に今日の日付で詰めており、呼び出し元の値が変更前と同じかどうかをここでは見ていなかった。その後、変更前と同じ値のときに通る分岐(else側)がそのpatchをそのままUPDATEに渡していたため、値が変わっていなくても日付フィールドだけは今日の日付に上書きされていた。
Q2この日付は何の根拠として使われていたのですか?
invoice_dateは入金遅延30日判定・督促の起点として使われ、paid_dateは領収証や凍結帳票の日付根拠として使われていた。どちらも「いつ状態が遷移したか」を表すはずの列で、管理画面のチェックボックスを押し直すだけの操作で黙って今日の日付に動いてしまうと、督促の起点や帳票の日付が実際の遷移日からずれる。
Q3直し方はpatchを作る側とUPDATEを呼ぶ側のどちらを直したのですか?
patchオブジェクトを作る側を直した。日付を詰める条件に『変更前の値が同じ状態ではない』という条件(prevPayment !== '請求済' など)を追加し、遷移が実際に起きたときだけpatchに日付が入るようにした。UPDATEを呼ぶ側(変更前と同じ値のときに通るelse分岐)は変更していない。
確認した環境
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
- 2026-07-25 のレビュー指摘で発覚・同日のコミットで修正
この記事の根拠
- TypeScriptファイル 299〜305行目コミット b80c3f0
- TypeScriptファイル 311〜326行目コミット b80c3f0
- TypeScriptファイル 301〜308行目コミット 952df2c
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。