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:
co-authored by
Claude Opus 5
parent
405cf0bf2d
commit
042bc8597d
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user