Unconditional Update Resets a Date Column on No-op Saves
This article may contain affiliate links. Its content is not affected by advertising.
In short
Building the UPDATE patch set a date field whenever the new status equaled a given value, with no check against the previous value, so resaving the same status silently overwrote the date to today.
Conclusion
The function that updates a contract’s payment status built its UPDATE patch by setting date fields (invoice_date / paid_date) unconditionally, so simply resaving the same status value in the admin screen still overwrote a date column that was only supposed to record an actual status transition. invoice_date anchors the overdue-reminder clock, and paid_date is the date basis printed on receipts and frozen billing records — if either moves on every resave, printed dates drift away from the real transition date. The fix adds one condition: only set the date when the previous value differs from the new one, i.e. when a transition actually happened.
Symptom
The function that updates a contract’s payment status first read the previous value, then built the patch object it would pass to UPDATE.
// actions.ts:299-305 (before)
const prevPayment = before?.payment_status as string | null | undefined;
// Invoice/paid dates are JST-based (same as reconcilePayment). UTC would roll
// over to the previous day in early JST mornings.
const today = jstToday();
const patch: Record<string, unknown> = { payment_status: payment, payment_status_auto: false };
if (payment === "invoiced") patch.invoice_date = today;
if (payment === "paid") patch.paid_date = today;
prevPayment (the previous value) had already been fetched at this point, but it was not used in the condition that sets patch.invoice_date / patch.paid_date. Whenever payment was "invoiced", the code set today’s date regardless of whether this was a brand-new transition or simply re-saving a row that was already "invoiced".
That patch was then passed to UPDATE further down, inside a branch.
// actions.ts:311-326 (before)
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) {
// Another operation (webhook / a concurrent tab) already transitioned it first.
// Do nothing, to avoid double-issuing billing records.
revalidatePath("/admin/contracts");
return;
}
} else {
await supabase.from("contracts").update(patch).eq("id", id);
}
When payment !== prevPayment (the value is genuinely changing), the code attempts a conditional UPDATE to claim the row, and backs off cleanly if it can’t — guarding against double submits and concurrent operations. The problem is the else branch: when payment === prevPayment (the admin re-clicked the same checkbox and resent the same value), that branch passed patch straight into UPDATE with no extra condition at all — and that patch, as shown above, already had today’s date baked in unconditionally.
Cause
This code’s conditional UPDATE, keyed off the previous value, exists to prevent a transition race (double-click, concurrent tabs, a webhook landing at the same time). That design works fine for the race itself. The bug came from building the patch object as a separate step from passing it into UPDATE: whether to add a date got decided earlier, and on a different condition (just the new payment value) than whether to actually guard against a race. prevPayment — the exact value that could tell whether a transition had really happened — was already sitting there, but it was never consulted when deciding what to put in the patch.
As a result, the payment === prevPayment path (resaving the same value) still carried a date field meant only for an actual transition, and that date landed in the row even though nothing had transitioned.
The fix
A comparison against the previous value was added to the condition that sets each date.
// actions.ts:301-308 (after)
// ⚠ Date columns are only written on an actual transition (same rule as signed_date).
// Writing them on a same-value resave means re-clicking the checkbox on an
// already-paid row silently bumps paid_date (the date basis for receipts and
// frozen billing records) to today, and resaving "invoiced" silently resets
// invoice_date (the anchor for the 30-day-overdue check).
const today = jstToday();
const patch: Record<string, unknown> = { payment_status: payment, payment_status_auto: false };
if (payment === "invoiced" && prevPayment !== "invoiced") patch.invoice_date = today;
if (payment === "paid" && prevPayment !== "paid") patch.paid_date = today;
The else branch (the UPDATE call that runs on an unchanged value) was left exactly as it was. Because patch itself no longer carries a date field in that case, running UPDATE through the else branch no longer touches any column besides payment_status and payment_status_auto. The conditional-UPDATE structure that prevents transition races wasn’t touched at all — only the condition for “should a date be written” was aligned with “did a transition actually happen.”
Preventing a repeat
This code already had the one piece of information needed to tell whether a transition had really happened — the previous value. The bug happened because the decision that uses that information (whether to run the race-guarded UPDATE) and the decision that doesn’t (what to put in the patch) were written separately inside the same function. Whenever a function has more than one condition deciding what an UPDATE writes versus whether it should write at all, both need to be checked against the same underlying premise — here, “did a transition actually happen.” Updating one and forgetting the other is exactly how this kind of silent inconsistency survives.
Frequently asked questions
Q1Why did resaving the same status move the date at all?
The code building the patch set invoice_date whenever payment === 'invoiced' and paid_date whenever payment === 'paid', unconditionally. The branch for an unchanged value (the else branch) passed that same patch straight into UPDATE, so even a no-op save overwrote the date with today.
Q2What was this date actually used for?
invoice_date anchors a 30-day-overdue reminder check; paid_date is the date printed on receipts and frozen billing records. Both should record when a transition happened. Re-clicking the same checkbox silently moved that date away from the real transition date.
Q3Was the fix on the side that builds the patch, or the side that calls UPDATE?
The patch-building side. A condition was added — the previous value must differ from the new one (prevPayment !== 'invoiced') — so the date is added only when a transition actually happened. The UPDATE call, including the else branch for an unchanged value, was left untouched.
Environment verified
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
- Found during a 2026-07-25 code review, fixed the same day
What this article is based on
- TypeScript file lines 299-305commit b80c3f0
- TypeScript file lines 311-326commit b80c3f0
- TypeScript file lines 301-308commit 952df2c
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.