feat(import): validate recurring costs at the boundary
The one check that is load-bearing rather than tidy: a recurrence interval below 1, or a fractional one, is REFUSED rather than clamped. The projection walk terminates only if every step strictly advances, and a clamped zero would hand the owner a schedule their file does not describe — silently. Purchase.source is checked too: the cost it names must exist, and the occurrence date must parse. PURCHASE_DATE already accepts both YYYY-MM and YYYY-MM-DD, which is exactly the range a slot date can take.
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>, 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;
|
||||
|
||||
Reference in New Issue
Block a user