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/);
+21 -4
View File
@@ -1,5 +1,7 @@
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
import { migrate } from "../store/migrations.ts";
import { monthOf } from "../domain/months.ts";
import { toDayOrdinal } from "../domain/days.ts";
export class ImportValidationError extends Error {
constructor(message: string) {
@@ -227,9 +229,10 @@ function requireSplits(
* split points at a real post, every purchase has exactly one remainder
* absorber, every MonthId parses, every rule version resolves unambiguously
* to a number rather than to NaN or a missing rate, and every recurring
* cost's startDate is granular enough for its recurrence kind (day-granular
* for everyNDays/everyNWeeks, since occurrencesOf hands it straight to
* addDays).
* cost's startDate is both granular enough for its recurrence kind
* (day-granular for everyNDays/everyNWeeks, since occurrencesOf hands it
* straight to addDays) and calendar-valid (no "2026-09-31"), since shape and
* range alone let an impossible date through.
*/
export function parseDatasetJson(text: string): Dataset {
let raw: unknown;
@@ -344,11 +347,25 @@ export function parseDatasetJson(text: string): Dataset {
// granularity; the other two kinds reach `addDays` and need a
// day-granular startDate specifically. `endedFrom` needs no such check —
// it is only ever compared lexicographically, never fed to `addDays`.
if (cost.recurrence.kind !== "everyNMonths" && !DAY_GRANULAR_DATE.test(cost.startDate)) {
const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate);
if (cost.recurrence.kind !== "everyNMonths" && !dayGranular) {
throw new ImportValidationError(
`${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`,
);
}
// Shape and range (checked above by PURCHASE_DATE/DAY_GRANULAR_DATE) still
// let a calendar-impossible date through, e.g. "2026-09-31" (September has
// 30 days) or "2026-02-30". Route it through the same domain parsers the
// fold itself uses, so an impossible date is refused here instead of
// throwing three months later out of `foldBalances`.
try {
if (dayGranular) toDayOrdinal(cost.startDate);
else monthOf(cost.startDate);
} catch (error) {
throw new ImportValidationError(
`${label} has a calendar-impossible start date "${cost.startDate}" (${(error as Error).message})`,
);
}
if (!ANCHORINGS.has(cost.anchoring)) {
throw new ImportValidationError(