diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 0766f68..0a9d0a6 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -144,3 +144,81 @@ describe("purchase dates", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-01-32/); }); }); + +describe("rule versions on import", () => { + const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent }); + + test("accepts a well-formed series", () => { + const data = populated(); + data.posts[0]!.rules = [ + { from: "2026-01", rule: pct(10) }, + { from: "2026-07", rule: pct(15) }, + ]; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("accepts an empty series — an unbudgeted post is legal", () => { + const data = populated(); + data.posts[0]!.rules = []; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("rejects a post whose rules is missing or not an array", () => { + const data = populated() as any; + delete data.posts[0].rules; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/rules/); + }); + + test("rejects a malformed from month", () => { + const data = populated(); + data.posts[0]!.rules = [{ from: "2026-1", rule: pct(10) }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/); + }); + + test("rejects an out-of-range from month", () => { + const data = populated(); + data.posts[0]!.rules = [{ from: "2026-13", rule: pct(10) }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/); + }); + + test("rejects two versions sharing a month, which would be ambiguous", () => { + const data = populated(); + data.posts[0]!.rules = [ + { from: "2026-04", rule: pct(10) }, + { from: "2026-04", rule: pct(15) }, + ]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-04/); + }); + + test("does NOT reject a percentage above 100", () => { + const data = populated(); + data.posts[0]!.rules = [{ from: "2026-01", rule: pct(150) }]; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("does NOT reject a negative fixed amount", () => { + const data = populated(); + data.posts[0]!.rules = [ + { from: "2026-01", rule: { kind: "fixed", amount: { amount: -500, currency: "DKK" } } }, + ]; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + // Without this, toBase() throws MissingRateError deep inside the balance + // fold instead of at the import boundary. + test("rejects a fixed rule in an unsupported currency", () => { + const data = populated() as any; + data.posts[0].rules = [ + { from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "XYZ" } } }, + ]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/XYZ/); + }); + + // Without this, resolveRule() falls through to the percentage branch and + // every figure downstream of it becomes NaN, silently. + test("rejects a rule of an unrecognized kind", () => { + const data = populated() as any; + data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 8566f5b..c2dbf81 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -54,7 +54,8 @@ function requireArray(data: Record, key: string): unknown[] { /** * 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, and every MonthId parses. + * absorber, every MonthId parses, and every rule version resolves + * unambiguously to a number rather than to NaN or a missing rate. */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -89,6 +90,43 @@ export function parseDatasetJson(text: string): Dataset { const postIds = new Set(dataset.posts.map((p) => p.id)); for (const post of dataset.posts) { requireCurrency(post.currency, `post "${post.name}"`); + + const rules = (post as unknown as Record).rules; + if (!Array.isArray(rules)) { + throw new ImportValidationError(`Post "${post.name}" is missing its rules array`); + } + const seenFrom = new Set(); + for (const version of rules as Array>) { + const from = String(version.from); + if (!MONTH_ID.test(from)) { + throw new ImportValidationError( + `Post "${post.name}" has a rule with an invalid start month "${from}"`, + ); + } + if (seenFrom.has(from)) { + throw new ImportValidationError( + `Post "${post.name}" has two rules starting in "${from}"; the effective rule would be ambiguous`, + ); + } + seenFrom.add(from); + + // The rule's VALUE is deliberately unvalidated — a percentage above 100 + // and a negative amount are both legal. Its SHAPE is not: an unknown + // kind falls through resolveRule's percentage branch and turns every + // figure after it into NaN, and an unsupported currency throws + // MissingRateError deep inside the fold rather than here. + const rule = (version.rule ?? {}) as { + kind?: unknown; + amount?: { currency?: unknown }; + }; + if (rule.kind === "fixed") { + requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`); + } else if (rule.kind !== "percentOfIncome") { + throw new ImportValidationError( + `Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`, + ); + } + } } for (const month of dataset.months) {