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
2026-09-05 22:42:28 +02:00
parent 6d63aafab4
commit 8d8407becf
4 changed files with 233 additions and 40 deletions
+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)}"`,
);
}
}
}
}
+61
View File
@@ -886,3 +886,64 @@ describe("editing a charge's date backwards re-anchors instead of throwing", ()
expect(dates[0]).toBe("2026-09-19"); // 2026-08-20 + 30 days
});
});
describe("anchoring cannot change once a cost has charges", () => {
function seeded(anchoring: "calendar" | "lastCharge") {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
name: "Bill", 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: post.id, value: 100, absorbsRemainder: true }],
});
return { d, cost };
}
test("changing anchoring is allowed while the cost has no charges", () => {
const { d, cost } = seeded("calendar");
actions.updateRecurringCost(d, cost.id, { anchoring: "lastCharge", startDate: "2026-09-01" });
expect(d.recurring[0]!.anchoring).toBe("lastCharge");
});
test("changing anchoring is REFUSED once a charge exists", () => {
// There is no honest way to invent which slot each existing charge
// settled, and guessing would leave real money settling nothing.
const { d, cost } = seeded("lastCharge");
actions.confirmOccurrence(d, cost.id, "2026-09-01", {});
expect(() => actions.updateRecurringCost(d, cost.id, { anchoring: "calendar" }))
.toThrow(/charge/i);
});
test("other fields are still editable on a cost with charges", () => {
const { d, cost } = seeded("lastCharge");
actions.confirmOccurrence(d, cost.id, "2026-09-01", {});
actions.updateRecurringCost(d, cost.id, { name: "Renamed" });
expect(d.recurring[0]!.name).toBe("Renamed");
});
test("setting anchoring to its CURRENT value is not a change", () => {
const { d, cost } = seeded("lastCharge");
actions.confirmOccurrence(d, cost.id, "2026-09-01", {});
expect(() => actions.updateRecurringCost(d, cost.id, { anchoring: "lastCharge" })).not.toThrow();
});
});
describe("confirmOccurrence by anchoring", () => {
test("an anchored confirmation records NO occurrenceDate", () => {
const d = createSeedDataset("2026-09");
const post = d.posts[0]!;
const cost = actions.addRecurringCost(d, {
name: "Phone", archived: false,
amount: { amount: 300, currency: "DKK" }, startDate: "2026-09-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "lastCharge",
splitMode: "percent",
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }],
});
const p = actions.confirmOccurrence(d, cost.id, "2026-09-01", {});
expect(p.source).toEqual({ recurringId: cost.id });
expect(p.date).toBe("2026-09-01");
});
});
+49 -11
View File
@@ -4,6 +4,7 @@ import { toDayOrdinal } from "../domain/days.ts";
import { roundMoney } from "../domain/money.ts";
import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts";
import type {
Anchoring,
Currency,
CurrencyDef,
Dataset,
@@ -398,11 +399,15 @@ function requireRecurrence(recurrence: Recurrence): Recurrence {
const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
/**
* `everyNMonths` walks the cursor through `monthOf`, so it tolerates either
* granularity of `startDate`. `everyNDays` and `everyNWeeks` hand it straight
* to `addDays`, which throws on a month-only date — so those kinds require a
* day-granular `startDate` here, at the boundary, rather than three months
* later when a fold walks off the end of it.
* `everyNMonths` walks the cursor through `monthOf` under `calendar`
* anchoring, so a month-only `startDate` is fine there — rent occurs IN a
* month. Under `lastCharge` anchoring the SAME kind walks day-precisely
* (`stepFrom`'s `addMonthsToDate` branch), because the next due date is
* computed from a real charge date, so it needs a real day to carry forward.
* `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays`
* regardless of anchoring, which throws on a month-only date — so those two
* kinds always require a day-granular `startDate` here, at the boundary,
* rather than three months later when a fold walks off the end of it.
*
* Shape alone is not enough: "2026-09-31" is day-SHAPED but calendar-
* impossible (September has 30 days), and would otherwise reach `addDays` (or
@@ -411,11 +416,16 @@ const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
* a month-granular one through `monthOf` — both already throw on exactly
* that — and translate whatever they throw into this file's own error style.
*/
function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void {
function requireStartDateGranularity(
recurrence: Recurrence,
startDate: IsoDate,
anchoring: Anchoring,
): void {
const dayGranular = DAY_GRANULAR_DATE.test(startDate);
if (recurrence.kind !== "everyNMonths" && !dayGranular) {
const needsDay = recurrence.kind !== "everyNMonths" || anchoring === "lastCharge";
if (needsDay && !dayGranular) {
throw new Error(
`A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
`A "${recurrence.kind}" recurrence anchored to ${anchoring} needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
);
}
try {
@@ -433,7 +443,7 @@ export function addRecurringCost(
cost: Omit<RecurringCost, "id" | "order">,
): RecurringCost {
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate);
requireStartDateGranularity(recurrence, cost.startDate, cost.anchoring);
const created: RecurringCost = {
...cost,
id: newId(),
@@ -468,11 +478,30 @@ export function updateRecurringCost(
}
// Runs on every update, not just one that touches recurrence or startDate:
// either field alone can break the pairing (a kind flip against an
// untouched month-only startDate is exactly how this bug was reached).
// untouched month-only startDate is exactly how this bug was reached), and
// an anchoring flip against an untouched month-only startDate is the same
// shape of bug one level up.
requireStartDateGranularity(
resolved.recurrence ?? cost.recurrence,
resolved.startDate ?? cost.startDate,
changes.anchoring ?? cost.anchoring,
);
if (changes.anchoring && changes.anchoring !== cost.anchoring) {
// Switching calendar -> lastCharge could strip the now-forbidden slot ids
// mechanically, but the reverse cannot: there is no honest way to invent
// which slot each existing charge settled, and guessing would leave real
// money claiming slots that are not on the new grid. Ending the cost and
// creating a new one keeps the history and states the change plainly.
const charged = draft.purchases.some((p) => p.source?.recurringId === cost.id);
if (charged) {
throw new Error(
`"${cost.name}" already has charges recorded, so its anchoring cannot change. ` +
`End this cost and create a new one with the anchoring you want — its history stays intact.`,
);
}
}
if (changes.amount) {
resolved.amount = roundMoneyValue(draft, changes.amount);
}
@@ -558,6 +587,12 @@ export function restoreRecurringCost(draft: Dataset, id: RecurringCostId): void
* anchoring it is the latter that rebases the series — which is the whole of
* the phone-bill behaviour.
*
* A `lastCharge` cost has no grid, so its charge names no slot: `source`
* carries `recurringId` alone, and `occurrenceDate` — the caller's projected
* cursor, not a real slot — is dropped rather than stored. Recording it would
* describe a schedule the app will not compute (`confirmationsFor` in
* `occurrences.ts` is calendar-only and throws on exactly this shape).
*
* Deleting the purchase later un-confirms the slot, with nothing to reconcile.
*/
export function confirmOccurrence(
@@ -576,6 +611,9 @@ export function confirmOccurrence(
// cost's, and `mutate` clones the draft rather than deep-freezing it.
splits: cost.splits.map((split) => ({ ...split })),
schedule: null,
source: { recurringId, occurrenceDate },
source:
cost.anchoring === "lastCharge"
? { recurringId }
: { recurringId, occurrenceDate },
});
}