diff --git a/src/domain/months.test.ts b/src/domain/months.test.ts index b04d05e..119b171 100644 --- a/src/domain/months.test.ts +++ b/src/domain/months.test.ts @@ -108,3 +108,12 @@ test("monthOf still rejects out-of-range and malformed month-only values", () => expect(() => monthOf("2026-00")).toThrow(/2026-00/); expect(() => monthOf("2026-9")).toThrow(/2026-9/); }); + +// Residual C1: the regex used to be `/^(\d{4})-(\d{2})/` with no trailing +// anchor, so it matched a "YYYY-MM" PREFIX of a longer string and silently +// dropped the rest — "2026-091" read back as September 2026. Such a value +// would then be un-re-importable, since PURCHASE_DATE (src/export/json.ts) +// rejects it outright. +test("monthOf rejects a value that is only a prefix match, not a full one", () => { + expect(() => monthOf("2026-091")).toThrow(/2026-091/); +}); diff --git a/src/domain/months.ts b/src/domain/months.ts index 6fb0e2c..ddccf7c 100644 --- a/src/domain/months.ts +++ b/src/domain/months.ts @@ -23,8 +23,14 @@ function fromOrdinal(ordinal: number): MonthId { return format(Math.floor(ordinal / 12), (ordinal % 12) + 1); } +// Anchored at both ends: "YYYY-MM" or "YYYY-MM-DD" only, never a prefix of +// something longer. Dual acceptance (with or without a day) is deliberate and +// load-bearing — do not narrow it to one form. An unanchored match once let +// "2026-091" through as September 2026, silently dropping the trailing "1". +const MONTH_OF_DATE = /^(\d{4})-(\d{2})(?:-\d{2})?$/; + export function monthOf(date: IsoDate): MonthId { - const match = /^(\d{4})-(\d{2})/.exec(date); + const match = MONTH_OF_DATE.exec(date); if (!match) throw new Error(`Invalid IsoDate: ${date}`); const month = Number(match[2]); if (month < 1 || month > 12) throw new Error(`Invalid IsoDate: ${date}`); diff --git a/src/export/json.test.ts b/src/export/json.test.ts index ce87074..4e436d7 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -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/); diff --git a/src/export/json.ts b/src/export/json.ts index babaf50..3f6c17b 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -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( diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 8c97ddd..30b9f44 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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", () => { diff --git a/src/store/actions.ts b/src/store/actions.ts index edbefe9..0ef951f 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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( diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts index 4b011b9..e6f3dad 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -36,6 +36,20 @@ describe("isValidStartDate", () => { test("a day-granular kind rejects a malformed day-granular-shaped value", () => { expect(isValidStartDate("2026-02-30", "everyNDays")).toBe(false); // no Feb 30 }); + + // Residual C1: `monthOf` only extracts the month and never checks the day, + // so a day-SHAPED but calendar-impossible value ("2026-09-31" — September + // has 30 days) used to pass here even under everyNMonths, and could then be + // stored, only for foldBalances to throw three months later. + test("everyNMonths still rejects a day-granular value with an impossible day", () => { + expect(isValidStartDate("2026-09-31", "everyNMonths")).toBe(false); + }); + + test("everyNMonths accepts a real leap-day and rejects a non-leap Feb 29", () => { + expect(isValidStartDate("2024-02-29", "everyNMonths")).toBe(true); // 2024 is a leap year + expect(isValidStartDate("2026-02-29", "everyNMonths")).toBe(false); // 2026 is not + expect(isValidStartDate("2026-02-28", "everyNMonths")).toBe(true); + }); }); // C1: switching the unit dropdown to a day-granular kind must expand a diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index ae8c7c8..fbaba4f 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -32,6 +32,12 @@ export function isDayGranular(date: IsoDate): boolean { * `addDays` directly. Reuses the domain's own parsers rather than a second, * possibly-diverging regex. * + * A day-SHAPED value is routed through `toDayOrdinal` even under + * `everyNMonths`: `monthOf` only extracts the month and never checks the day, + * so without this a calendar-impossible date like "2026-09-31" (September has + * 30 days) would pass here and could still be stored, only to throw three + * months later out of the fold. + * * Exported for `RecurringSection.test.ts` — this is the one thing standing * between the "Starts" field and C2 (an empty or malformed value bricking * every route the fold touches), so it is tested directly rather than only @@ -39,7 +45,7 @@ export function isDayGranular(date: IsoDate): boolean { */ export function isValidStartDate(value: string, kind: Recurrence["kind"]): boolean { try { - if (kind === "everyNMonths") monthOf(value); + if (kind === "everyNMonths" && !isDayGranular(value)) monthOf(value); else toDayOrdinal(value); return true; } catch {