diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7e25dee..8f482ee 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -253,3 +253,142 @@ describe("the decimal-places setting on import", () => { expect(out.settings.digits).toBe(2); }); }); + +describe("recurring cost validation", () => { + function withRecurring(recurring: unknown[]): string { + const data: any = createSeedDataset("2026-01"); + data.posts[0].id = "housing"; + data.recurring = recurring; + return JSON.stringify(data); + } + + const valid = { + id: "r1", + name: "Rent", + order: 0, + archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }; + + test("a well-formed cost is accepted", () => { + expect(parseDatasetJson(withRecurring([valid])).recurring.length).toBe(1); + }); + + test("an undefined currency is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, amount: { amount: 1, currency: "XYZ" } }]))) + .toThrow(/Unsupported currency/); + }); + + test("a malformed start date is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-13" }]))) + .toThrow(/start date/); + }); + + test("a malformed endedFrom is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "nope" }]))) + .toThrow(/ended-from date/); + }); + + test("an interval below 1 is refused, not clamped", () => { + // Clamping would silently change the bill's schedule. The walk in + // occurrences.ts cannot terminate without this. + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 0 } }]))) + .toThrow(/at least 1/); + }); + + test("a fractional interval is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 1.5 } }]))) + .toThrow(/whole number/); + }); + + test("an unknown recurrence kind is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }]))) + .toThrow(/unknown kind/); + }); + + test("a weekday outside 0-6 is refused", () => { + expect(() => + parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }])), + ).toThrow(/weekday/); + }); + + test("an unknown anchoring is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, anchoring: "whenever" }]))) + .toThrow(/anchoring/); + }); + + test("splits must have exactly one remainder absorber", () => { + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [ + { postId: "housing", value: 50, absorbsRemainder: true }, + { postId: "housing", value: 50, absorbsRemainder: true }, + ], + }])), + ).toThrow(/absorbsRemainder/); + }); + + test("a split naming an unknown post is refused", () => { + expect(() => + parseDatasetJson(withRecurring([{ + ...valid, + splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }], + }])), + ).toThrow(/unknown post/); + }); + + test("no splits at all is refused", () => { + expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: [] }]))) + .toThrow(/no splits/); + }); +}); + +describe("Purchase.source validation", () => { + function withSource(source: unknown): string { + const data: any = createSeedDataset("2026-01"); + data.posts[0].id = "housing"; + data.recurring = []; + data.purchases = [{ + id: "p1", + date: "2026-01", + description: "Rent", + total: { amount: 8000, currency: "DKK" }, + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + schedule: null, + source, + }]; + return JSON.stringify(data); + } + + test("a purchase with no source is fine", () => { + const data: any = createSeedDataset("2026-01"); + data.recurring = []; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("a source naming an unknown cost is refused", () => { + expect(() => parseDatasetJson(withSource({ recurringId: "ghost", occurrenceDate: "2026-01" }))) + .toThrow(/unknown recurring cost/); + }); + + test("a source with a malformed occurrence date is refused", () => { + const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" })); + data.recurring = [{ + id: "r1", name: "Rent", order: 0, archived: false, + amount: { amount: 8000, currency: "DKK" }, + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "calendar", + splitMode: "percent", + splits: [{ postId: "housing", value: 100, absorbsRemainder: true }], + }]; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/occurrence date/); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 6158d19..cbdd6a3 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -36,6 +36,9 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; */ const PURCHASE_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"]); + /** * `Currency` is an open string now, so this — not the type system — is what * stops a code the dataset never defined reaching the fold, where it would @@ -118,6 +121,39 @@ function requireArray(data: Record, key: string): unknown[] { return value; } +/** + * `n` is checked hard rather than clamped. The projection walk in + * `domain/occurrences.ts` terminates only if every step strictly advances, and + * a clamped zero would silently give the owner a schedule their file does not + * describe. Rejecting tells them the file is wrong; clamping tells them + * nothing and quietly changes a bill. + */ +function requireRecurrence(recurrence: any, label: string): void { + if (!recurrence || !RECURRENCE_KINDS.has(recurrence.kind)) { + throw new ImportValidationError( + `${label} has a recurrence of unknown kind "${String(recurrence?.kind)}"`, + ); + } + if (typeof recurrence.n !== "number" || !Number.isInteger(recurrence.n)) { + throw new ImportValidationError( + `${label} has a recurrence interval that is not a whole number: ${String(recurrence.n)}`, + ); + } + if (recurrence.n < 1) { + throw new ImportValidationError( + `${label} has a recurrence interval of ${recurrence.n}; it must be at least 1`, + ); + } + if (recurrence.kind === "everyNWeeks") { + const { weekday } = recurrence; + if (typeof weekday !== "number" || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) { + throw new ImportValidationError( + `${label} has a weekday of ${String(weekday)}; it must be 0-6, where 0 is Sunday`, + ); + } + } +} + /** * 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 @@ -210,6 +246,43 @@ export function parseDatasetJson(text: string): Dataset { } } + const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; + dataset.recurring = recurring; + const recurringIds = new Set(recurring.map((cost: any) => cost.id)); + + for (const cost of recurring) { + const label = `Recurring cost "${cost.name}"`; + requireCurrency(cost.amount?.currency, label, defined); + + if (!PURCHASE_DATE.test(cost.startDate)) { + throw new ImportValidationError(`${label} has an invalid start date "${cost.startDate}"`); + } + if (cost.endedFrom !== undefined && !PURCHASE_DATE.test(cost.endedFrom)) { + throw new ImportValidationError(`${label} has an invalid ended-from date "${cost.endedFrom}"`); + } + requireRecurrence(cost.recurrence, label); + + if (!ANCHORINGS.has(cost.anchoring)) { + throw new ImportValidationError( + `${label} has an unknown anchoring "${String(cost.anchoring)}"`, + ); + } + if (!Array.isArray(cost.splits) || cost.splits.length === 0) { + throw new ImportValidationError(`${label} has no splits; at least one split is required`); + } + const absorbers = cost.splits.filter((s: any) => s.absorbsRemainder).length; + if (absorbers !== 1) { + throw new ImportValidationError( + `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, + ); + } + for (const split of cost.splits) { + if (!postIds.has(split.postId)) { + throw new ImportValidationError(`${label} references unknown post "${split.postId}"`); + } + } + } + for (const month of dataset.months) { if (!MONTH_ID.test(month.id)) { throw new ImportValidationError(`Invalid month id "${month.id}"`); @@ -246,6 +319,19 @@ export function parseDatasetJson(text: string): Dataset { } requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined); } + + if (purchase.source !== undefined) { + if (!recurringIds.has(purchase.source.recurringId)) { + throw new ImportValidationError( + `${label} names unknown recurring cost "${purchase.source.recurringId}"`, + ); + } + if (!PURCHASE_DATE.test(purchase.source.occurrenceDate)) { + throw new ImportValidationError( + `${label} has an invalid occurrence date "${purchase.source.occurrenceDate}"`, + ); + } + } } return dataset;