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:
1 parent
6d63aafab4
commit
8d8407becf
4 files changed
+233
-40
No files matched your search
@@ -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
@@ -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)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user