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);
});
});