diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 59620b6..8c97ddd 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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); diff --git a/src/store/actions.ts b/src/store/actions.ts index 9a3d57e..edbefe9 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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 { + 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);