From 397bb245d8c40ca6d2ca2f9cd6773454fc618194 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 07:57:30 +0200 Subject: [PATCH] fix(import): reject a startDate/kind granularity mismatch, and a non-array recurring field (C1, M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDatasetJson now enforces the same rule as the store: everyNDays and everyNWeeks require a day-granular startDate, since occurrencesOf hands it straight to addDays. Without this, a hand-edited or exported-then-mutated v7 file could import a cost that bricks every route touching the fold. Updates the module docstring's claim that it "validates far enough that the fold cannot throw" to actually cover this case. Also switches the recurring field to requireArray, matching its four sibling collections (posts/months/purchases/fxRates) — a non-array `recurring` used to silently import as empty instead of being rejected. --- src/export/json.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++ src/export/json.ts | 26 ++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7f7c861..ce87074 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -62,6 +62,14 @@ describe("validation", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/); }); + test("rejects a non-array recurring field instead of silently importing it as empty (M7)", () => { + // The other four sibling collections already go through requireArray; + // recurring used to fall back to [] instead of being rejected here. + const data = populated() as any; + data.recurring = "not an array"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/recurring/); + }); + test("rejects a purchase whose splits reference an unknown post", () => { const data = populated(); data.purchases[0]!.splits[0]!.postId = "ghost"; @@ -306,6 +314,40 @@ describe("recurring cost validation", () => { .toThrow(/whole number/); }); + test("everyNMonths accepts either startDate granularity (C1)", () => { + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01" }])).recurring.length).toBe(1); + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01-15" }])).recurring.length).toBe(1); + }); + + test("everyNDays with a month-only startDate is refused (C1)", () => { + // This is the exact reachable bug: occurrencesOf hands a month-only + // startDate straight to addDays for a day-granular kind and throws deep + // inside the fold. Reject it here instead, at the import boundary. + expect(() => + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01", recurrence: { kind: "everyNDays", n: 30 } }]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNWeeks with a month-only startDate is refused (C1)", () => { + expect(() => + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2026-01", recurrence: { kind: "everyNWeeks", n: 1, weekday: 2 } }, + ]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNDays with a day-granular startDate is accepted (C1)", () => { + expect( + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 30 } }]), + ).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 b9d0c05..babaf50 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -35,6 +35,14 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; * throwing out of `monthOf()` deep inside the balance fold. */ const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/; +/** + * The day-granular subset of `PURCHASE_DATE`: "YYYY-MM-DD" only, no month-only + * form. `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays` + * (`src/domain/occurrences.ts`), which throws on a month-only date — so those + * kinds need this narrower shape, checked here rather than three months later + * inside a fold. + */ +const DAY_GRANULAR_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; const ANCHORINGS = new Set(["calendar", "lastCharge"]); const RECURRENCE_KINDS = new Set(["everyNMonths", "everyNDays", "everyNWeeks"]); @@ -217,8 +225,11 @@ function requireSplits( /** * Validates far enough that the fold cannot throw on the imported data: every * split points at a real post, every purchase has exactly one remainder - * absorber, every MonthId parses, and every rule version resolves - * unambiguously to a number rather than to NaN or a missing rate. + * 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). */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -306,7 +317,7 @@ export function parseDatasetJson(text: string): Dataset { } } - const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; + const recurring = requireArray(data, "recurring") as any[]; dataset.recurring = recurring; // Optional chaining, not `.id` directly: a null/non-object entry must fall // through to the loop below and fail there with an ImportValidationError, @@ -329,6 +340,15 @@ export function parseDatasetJson(text: string): Dataset { throw new ImportValidationError(`${label} has an invalid ended-from date "${cost.endedFrom}"`); } requireRecurrence(cost.recurrence, label); + // `everyNMonths` walks through `monthOf`, which accepts either + // 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)) { + throw new ImportValidationError( + `${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`, + ); + } if (!ANCHORINGS.has(cost.anchoring)) { throw new ImportValidationError(