fix(recurring): reject calendar-impossible startDate, not just wrongly-shaped (C1 residual)

requireStartDateGranularity (store/actions.ts) and its import-path twin
(export/json.ts) checked date SHAPE with a regex, not date VALIDITY, so
"2026-09-31" (September has 30 days) passed both and threw later out of
foldBalances instead of at the write boundary. isValidStartDate
(RecurringSection.tsx) had the same hole for a day-shaped value under
everyNMonths. Separately, monthOf's regex (domain/months.ts) was
unanchored at the end, so "2026-091" was silently read as September 2026.

Route day-shaped values through toDayOrdinal and month-shaped values
through monthOf (now anchored to accept only "YYYY-MM" or "YYYY-MM-DD" in
full, never a prefix) at all three write boundaries, converting whatever
they throw into each file's own error style.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-04 08:16:03 +02:00
1 parent 405cf0bf2d
commit 042bc8597d
8 files changed
+183 -8

No files matched your search

+70
View File
@@ -593,6 +593,76 @@ describe("recurring costs", () => {
expect(data.recurring[0]!.startDate).toBe("2026-09-01");
expect(data.recurring[0]!.recurrence.kind).toBe("everyNDays");
});
// Residual C1: shape alone ("YYYY-MM-DD") is not calendar validity.
// September has 30 days, so "2026-09-31" is day-SHAPED but impossible.
// `requireStartDateGranularity` used a shape-only regex and let this
// through even under everyNMonths; downstream, foldBalances throws
// "Invalid day in IsoDate" out of addDays/toDayOrdinal.
test("a calendar-impossible startDate is refused, even under everyNMonths", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09-31" }),
).toThrow(/2026-09-31/);
const cost = actions.addRecurringCost(data, rentInput);
expect(() =>
actions.updateRecurringCost(data, cost.id, { startDate: "2026-09-31" }),
).toThrow(/2026-09-31/);
// Refused, so nothing committed beyond the one successful add above.
expect(data.recurring.length).toBe(1);
expect(data.recurring[0]!.startDate).toBe(rentInput.startDate);
});
// Second half of the same reproduction: even if a calendar-impossible
// startDate is already sitting in the draft (e.g. from data written
// before this fix existed), switching the recurrence kind must not wave
// it through — requireStartDateGranularity re-validates on every update,
// not only ones that touch startDate.
test("switching kind against an already-impossible stored startDate is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
// Bypass the public API to simulate a bad value already on disk.
data.recurring[0]!.startDate = "2026-09-31";
expect(() =>
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 7 } }),
).toThrow(/2026-09-31/);
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
});
// Residual C1: monthOf's regex used to be unanchored (`/^(\d{4})-(\d{2})/`
// with no trailing `$`), so it matched a "YYYY-MM" PREFIX of a longer,
// malformed string and silently dropped the rest.
test("an unanchored-month-shaped garbage value is refused", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-091" }),
).toThrow(/2026-091/);
const cost = actions.addRecurringCost(data, rentInput);
expect(() =>
actions.updateRecurringCost(data, cost.id, { startDate: "2026-091" }),
).toThrow(/2026-091/);
});
test("legitimate calendar-valid dates still pass", () => {
const data = draft();
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" })).not.toThrow();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2024-02-29", // 2024 is a leap year
recurrence: { kind: "everyNDays", n: 1 },
}),
).not.toThrow();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-02-28", // 2026 is not a leap year
recurrence: { kind: "everyNDays", n: 1 },
}),
).not.toThrow();
});
});
test("ending sets both endedFrom and archived", () => {
+18 -2
View File
@@ -1,5 +1,6 @@
import { newId } from "../domain/seed.ts";
import { compareMonths, monthOf } from "../domain/months.ts";
import { toDayOrdinal } from "../domain/days.ts";
import { roundMoney } from "../domain/money.ts";
import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts";
import type {
@@ -402,14 +403,29 @@ const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
* to `addDays`, which throws on a month-only date — so those kinds require a
* day-granular `startDate` here, at the boundary, rather than three months
* later when a fold walks off the end of it.
*
* Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar-
* impossible (September has 30 days), and would otherwise reach `addDays` (or
* sit unnoticed under `everyNMonths`) and throw three months later out of the
* fold instead of here. Route a day-granular value through `toDayOrdinal` and
* a month-granular one through `monthOf` — both already throw on exactly
* that — and translate whatever they throw into this file's own error style.
*/
function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void {
if (recurrence.kind === "everyNMonths") return;
if (!DAY_GRANULAR_DATE.test(startDate)) {
const dayGranular = DAY_GRANULAR_DATE.test(startDate);
if (recurrence.kind !== "everyNMonths" && !dayGranular) {
throw new Error(
`A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
);
}
try {
if (dayGranular) toDayOrdinal(startDate);
else monthOf(startDate);
} catch (error) {
throw new Error(
`A "${recurrence.kind}" recurrence needs a valid startDate, not "${startDate}" (${(error as Error).message})`,
);
}
}
export function addRecurringCost(