fix(recurring): validate startDate granularity at both write paths (C1)

addRecurringCost and updateRecurringCost now reject a day-granular
recurrence (everyNDays/everyNWeeks) paired with a month-only startDate.
occurrencesOf hands startDate straight to addDays for those kinds, which
throws "Not a day-granular IsoDate" — reachable in two clicks by adding a
cost (always created as everyNMonths, startDate "YYYY-MM") and then
switching its unit in the dropdown without touching Starts.

updateRecurringCost checks the resolved pair (changes merged onto the
stored cost) on every call, not just one that touches recurrence or
startDate, since either field alone can break the pairing.

Also corrects endRecurringCost's doc comment: archiving does not hide a
recurring cost from the list, it dims it — the code already matches
PostsSection, only the comment was wrong (M6).
This commit is contained in:
Lucas Winther
2026-09-04 07:57:23 +02:00
parent 8078057daa
commit d1ccc65d72
2 changed files with 114 additions and 6 deletions
+80
View File
@@ -515,6 +515,86 @@ describe("recurring costs", () => {
).toThrow(/at least 1/);
});
describe("startDate granularity (C1)", () => {
test("adding a day-granular kind with a month-only startDate is refused", () => {
const data = draft();
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01",
recurrence: { kind: "everyNDays", n: 30 },
}),
).toThrow(/day-granular/);
expect(() =>
actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01",
recurrence: { kind: "everyNWeeks", n: 1, weekday: 1 },
}),
).toThrow(/day-granular/);
});
test("adding a day-granular kind with a day-granular startDate succeeds", () => {
const data = draft();
const cost = actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01-05",
recurrence: { kind: "everyNDays", n: 30 },
});
expect(cost.startDate).toBe("2026-01-05");
});
test("everyNMonths accepts either granularity", () => {
const data = draft();
expect(() => actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01" })).not.toThrow();
expect(() =>
actions.addRecurringCost(data, { ...rentInput, startDate: "2026-01-15" }),
).not.toThrow();
});
// This is the exact bug: a monthly cost is created (startDate "2026-09"),
// then the unit dropdown switches it to a day-granular kind WITHOUT
// touching startDate — the only way a real edit reaches this, since the
// add form always starts as everyNMonths.
test("updating just the recurrence kind against an untouched month-only startDate is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
expect(() =>
actions.updateRecurringCost(data, cost.id, { recurrence: { kind: "everyNDays", n: 30 } }),
).toThrow(/day-granular/);
expect(() =>
actions.updateRecurringCost(data, cost.id, {
recurrence: { kind: "everyNWeeks", n: 1, weekday: 3 },
}),
).toThrow(/day-granular/);
// Refused, so nothing committed — still the original month-granular kind.
expect(data.recurring[0]!.recurrence.kind).toBe("everyNMonths");
});
test("updating just startDate to a month-only value against a stored day-granular kind is refused", () => {
const data = draft();
const cost = actions.addRecurringCost(data, {
...rentInput,
startDate: "2026-01-05",
recurrence: { kind: "everyNDays", n: 30 },
});
expect(() => actions.updateRecurringCost(data, cost.id, { startDate: "2026-01" })).toThrow(
/day-granular/,
);
});
test("updating recurrence and startDate together to a consistent day-granular pair succeeds", () => {
const data = draft();
const cost = actions.addRecurringCost(data, { ...rentInput, startDate: "2026-09" });
actions.updateRecurringCost(data, cost.id, {
recurrence: { kind: "everyNDays", n: 30 },
startDate: "2026-09-01",
});
expect(data.recurring[0]!.startDate).toBe("2026-09-01");
expect(data.recurring[0]!.recurrence.kind).toBe("everyNDays");
});
});
test("ending sets both endedFrom and archived", () => {
const data = draft();
const cost = actions.addRecurringCost(data, rentInput);
+34 -6
View File
@@ -394,15 +394,35 @@ function requireRecurrence(recurrence: Recurrence): Recurrence {
return 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.
*/
function requireStartDateGranularity(recurrence: Recurrence, startDate: IsoDate): void {
if (recurrence.kind === "everyNMonths") return;
if (!DAY_GRANULAR_DATE.test(startDate)) {
throw new Error(
`A "${recurrence.kind}" recurrence needs a day-granular startDate ("YYYY-MM-DD"), not "${startDate}"`,
);
}
}
export function addRecurringCost(
draft: Dataset,
cost: Omit<RecurringCost, "id" | "order">,
): RecurringCost {
const recurrence = requireRecurrence(cost.recurrence);
requireStartDateGranularity(recurrence, cost.startDate);
const created: RecurringCost = {
...cost,
id: newId(),
order: draft.recurring.length,
recurrence: requireRecurrence(cost.recurrence),
recurrence,
amount: roundMoneyValue(draft, cost.amount),
splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency),
};
@@ -421,6 +441,13 @@ export function updateRecurringCost(
if (changes.recurrence) {
resolved.recurrence = requireRecurrence(changes.recurrence);
}
// 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).
requireStartDateGranularity(
resolved.recurrence ?? cost.recurrence,
resolved.startDate ?? cost.startDate,
);
if (changes.amount) {
resolved.amount = roundMoneyValue(draft, changes.amount);
}
@@ -454,13 +481,14 @@ export function moveRecurringCost(
}
/**
* Stops a bill from `from` onward and hides it from the list.
* Stops a bill from `from` onward and dims it in the list.
*
* Both fields, because they mean different things: `endedFrom` stops the
* PROJECTION, and `archived` only hides the row. Archiving alone would leave
* the bill projecting forever; setting `endedFrom` alone would leave a dead
* bill cluttering the list. Neither touches a past occurrence, so no
* historical figure moves.
* PROJECTION, and `archived` only flags the row — same as a post, it stays
* listed (sorted in, rendered at reduced opacity) rather than being filtered
* out. Archiving alone would leave the bill projecting forever; setting
* `endedFrom` alone would leave a dead bill undimmed in the list. Neither
* touches a past occurrence, so no historical figure moves.
*/
export function endRecurringCost(draft: Dataset, id: RecurringCostId, from: IsoDate): void {
const cost = requireRecurringCost(draft, id);