feat(validation): occurrenceDate is required by calendar, forbidden by anchored

Both boundaries enforce both directions, because a cost's anchoring now
determines what a charge against it may carry. A slot id on an anchored cost
describes a schedule the app will not compute.

startDate granularity widens: anchored monthly costs now need a real day,
since their next due date is computed day-precisely from a charge date.

Changing anchoring on a cost that already has charges is refused. Stripping
slot ids one way is mechanical; inventing them the other way is not, and a
guess would leave real money settling nothing.
This commit is contained in:
Lucas Winther committed 2026-09-05 22:42:28 +02:00
1 parent 6d63aafab4
commit 8d8407becf
4 files changed
+233 -40

No files matched your search

+76
View File
@@ -563,3 +563,79 @@ describe("purchase splits validation", () => {
expect(parseDatasetJson(exportDatasetJson(data)).purchases.length).toBe(1);
});
});
describe("the occurrenceDate / anchoring pairing", () => {
function withCostAndCharge(anchoring: string, source: any): string {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring, splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
}];
data.purchases = [{
id: "p1", date: "2026-09-20", description: "Bill",
total: { amount: 300, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
schedule: null, source,
}];
return JSON.stringify(data);
}
test("a calendar charge REQUIRES occurrenceDate", () => {
expect(() => parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1" })))
.toThrow(/occurrence date/i);
});
test("a calendar charge with occurrenceDate is accepted", () => {
expect(() =>
parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1", occurrenceDate: "2026-09" })),
).not.toThrow();
});
test("an anchored charge FORBIDS occurrenceDate", () => {
// Its presence describes a schedule the app will not compute.
expect(() =>
parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1", occurrenceDate: "2026-09" })),
).toThrow(/does not settle a slot|no slots|anchored/i);
});
test("an anchored charge without occurrenceDate is accepted", () => {
expect(() => parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1" }))).not.toThrow();
});
});
describe("startDate granularity under the anchored model", () => {
function withCost(anchoring: string, kind: string, startDate: string): string {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate, recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
anchoring, splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
}];
data.purchases = [];
return JSON.stringify(data);
}
test("anchored + everyNMonths now needs a DAY", () => {
expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09"))).toThrow(/day-granular/);
});
test("calendar + everyNMonths still accepts a month", () => {
expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09"))).not.toThrow();
});
test("calendar + everyNMonths still accepts a day", () => {
expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09-20"))).not.toThrow();
});
test("a calendar-impossible date is still refused", () => {
expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09-31"))).toThrow(/impossible/i);
});
});
+47 -29
View File
@@ -322,10 +322,6 @@ export function parseDatasetJson(text: string): Dataset {
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,
// not throw a raw TypeError here while merely collecting ids.
const recurringIds = new Set(recurring.map((cost: any) => cost?.id));
for (const cost of recurring) {
if (!cost || typeof cost !== "object") {
@@ -336,6 +332,16 @@ export function parseDatasetJson(text: string): Dataset {
const label = `Recurring cost "${cost.name}"`;
requireCurrency(cost.amount?.currency, label, defined);
// Validated before the startDate/granularity checks below, because the
// granularity rule for `everyNMonths` now depends on which anchoring the
// cost carries — read it here rather than deferring, so a malformed
// anchoring string does not silently read as "calendar" a few lines down.
if (!ANCHORINGS.has(cost.anchoring)) {
throw new ImportValidationError(
`${label} has an unknown anchoring "${String(cost.anchoring)}"`,
);
}
if (!PURCHASE_DATE.test(cost.startDate)) {
throw new ImportValidationError(`${label} has an invalid start date "${cost.startDate}"`);
}
@@ -343,14 +349,19 @@ 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 —
// `everyNMonths` walks through `monthOf` under `calendar` anchoring, which
// accepts either granularity — rent occurs IN a month. Under `lastCharge`
// the same kind is computed day-precisely from a real charge date
// (`stepFrom`'s `addMonthsToDate` branch), so it needs a real day too.
// `everyNDays`/`everyNWeeks` reach `addDays` regardless of anchoring and
// always need a day-granular startDate. `endedFrom` needs no such check —
// it is only ever compared lexicographically, never fed to `addDays`.
const dayGranular = DAY_GRANULAR_DATE.test(cost.startDate);
if (cost.recurrence.kind !== "everyNMonths" && !dayGranular) {
const needsDay = cost.recurrence.kind !== "everyNMonths" || cost.anchoring === "lastCharge";
if (needsDay && !dayGranular) {
throw new ImportValidationError(
`${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`,
`${label} has a "${cost.recurrence.kind}" recurrence anchored to "${cost.anchoring}" with a ` +
`month-only start date "${cost.startDate}"; it needs a day-granular startDate ("YYYY-MM-DD")`,
);
}
// Shape and range (checked above by PURCHASE_DATE/DAY_GRANULAR_DATE) still
@@ -381,11 +392,6 @@ export function parseDatasetJson(text: string): Dataset {
}
}
if (!ANCHORINGS.has(cost.anchoring)) {
throw new ImportValidationError(
`${label} has an unknown anchoring "${String(cost.anchoring)}"`,
);
}
requireSplits(cost.splits, cost.splitMode, label, postIds);
}
@@ -419,24 +425,36 @@ export function parseDatasetJson(text: string): Dataset {
// TypeError. `schedule?.slices ?? []` two blocks up is the same
// defensive style applied to a sibling field that IS legitimately null.
if (!purchase.source || typeof purchase.source !== "object") {
throw new ImportValidationError(`${label} has a source that is not an object`);
}
// Captured into a local: narrowing `purchase.source` above does not
// survive into the `find` callback below, which TypeScript treats as a
// fresh closure over a possibly-reassigned property.
const source = purchase.source;
const cost = recurring.find((c: any) => c?.id === source.recurringId);
if (!cost) {
throw new ImportValidationError(
`${label} has a source that is not an object: ${JSON.stringify(purchase.source)}`,
`${label} names unknown recurring cost "${source.recurringId}"`,
);
}
if (!recurringIds.has(purchase.source.recurringId)) {
throw new ImportValidationError(
`${label} names unknown recurring cost "${purchase.source.recurringId}"`,
);
}
// occurrenceDate is absent for a lastCharge purchase — Task 6 rewrites
// this to check that against the cost's own anchoring.
if (
purchase.source.occurrenceDate !== undefined && // rewritten in Task 6
!PURCHASE_DATE.test(purchase.source.occurrenceDate)
) {
throw new ImportValidationError(
`${label} has an invalid occurrence date "${purchase.source.occurrenceDate}"`,
);
// A calendar cost has a grid, and a charge against it settles one slot,
// so it must name that slot. An anchored (lastCharge) cost has no grid
// at all — its next due date comes from the charge's OWN date, not from
// any slot — so an occurrence date there describes a schedule this app
// will not compute; refuse it rather than silently ignore it.
if (cost.anchoring === "lastCharge") {
if (source.occurrenceDate !== undefined) {
throw new ImportValidationError(
`${label} carries an occurrence date, but "${cost.name}" is anchored to its last ` +
`charge and has no slots to settle`,
);
}
} else {
if (!PURCHASE_DATE.test(source.occurrenceDate as string)) {
throw new ImportValidationError(
`${label} has an invalid occurrence date "${String(source.occurrenceDate)}"`,
);
}
}
}
}