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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user