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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-04 08:16:03 +02:00
co-authored by Claude Opus 5
parent 405cf0bf2d
commit 042bc8597d
8 changed files with 183 additions and 8 deletions
+37
View File
@@ -348,6 +348,43 @@ describe("recurring cost validation", () => {
).toBe(1);
});
// Residual C1: PURCHASE_DATE/DAY_GRANULAR_DATE check shape and range
// ("31" is a valid day-of-month digit pair), not calendar validity.
// September has 30 days, so "2026-09-31" passed both regexes and the
// fold later threw "Invalid day in IsoDate" out of addDays/toDayOrdinal.
test("an impossible day on a day-granular kind is refused, not just shape-checked (residual C1)", () => {
expect(() =>
parseDatasetJson(
withRecurring([
{ ...valid, startDate: "2026-02-30", recurrence: { kind: "everyNDays", n: 1 } },
]),
),
).toThrow(/calendar-impossible/);
});
test("an impossible day is refused even under everyNMonths (residual C1)", () => {
expect(() =>
parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-09-31" }])),
).toThrow(/calendar-impossible/);
});
test("leap-year and non-leap-year Februaries are accepted (residual C1)", () => {
expect(
parseDatasetJson(
withRecurring([
{ ...valid, startDate: "2024-02-29", recurrence: { kind: "everyNDays", n: 1 } }, // 2024 is a leap year
]),
).recurring.length,
).toBe(1);
expect(
parseDatasetJson(
withRecurring([
{ ...valid, startDate: "2026-02-28", recurrence: { kind: "everyNDays", n: 1 } }, // 2026 is not
]),
).recurring.length,
).toBe(1);
});
test("an unknown recurrence kind is refused", () => {
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }])))
.toThrow(/unknown kind/);