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:
1 parent
405cf0bf2d
commit
042bc8597d
8 files changed
+183
-8
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user