diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 44aa658..235ebd4 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -188,6 +188,32 @@ describe("occurrencesOf — lastCharge anchoring: the phone bill", () => { }); }); +describe("occurrencesOf — lastCharge anchoring: a monthly bill paid early (I3)", () => { + const rent = cost({ + startDate: "2026-08", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", + }); + + test("paying a few days before month-end does not throw or rebase", () => { + // The slot is 2026-09; the owner pays on the 28th of August, four days + // early. That is completely ordinary and must not move the series + // backwards or make it fail to advance. + const paidEarly = new Map([["2026-09", confirmation("2026-09", "2026-08-28", "pE")]]); + const dates = occurrencesOf(rent, paidEarly, "2026-10").map((o) => o.date); + expect(dates).toEqual(["2026-08", "2026-09", "2026-10"]); + }); + + test("paying after the month it was for rebases forward, same as the phone bill", () => { + // Paid on 2026-09-05 for the 2026-08 slot: the confirmation's month is + // AFTER the cursor, so (mirroring the day-granular cap-hit case) the + // series rebases from there. + const paidLate = new Map([["2026-08", confirmation("2026-08", "2026-09-05", "pL")]]); + const dates = occurrencesOf(rent, paidLate, "2026-11").map((o) => o.date); + expect(dates).toEqual(["2026-08", "2026-10", "2026-11"]); + }); +}); + describe("occurrencesOf — termination", () => { test("a confirmation dated absurdly early throws rather than walking backwards", () => { // The ONLY way a valid recurrence fails to advance. Under lastCharge the diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index 443be4e..f5a55a8 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -126,8 +126,21 @@ export function occurrencesOf( // makes an early charge rebase the series. An unconfirmed occurrence steps // from its own projected date: the projection assumes bills are paid on // time, and self-corrects as they are confirmed. + // + // For `everyNMonths`, "moved" means the confirmation's MONTH, and paying + // a few days before the month turns over is ordinary — it must not rebase + // the series backwards. So a month-granular step runs from whichever is + // later, the cursor or the confirmation's month; an early payment within + // the same month simply does not rebase. Day/week-granular kinds keep + // stepping from the confirmation's exact date, unchanged. const from = - cost.anchoring === "lastCharge" && confirmation ? confirmation.date : cursor; + cost.anchoring === "lastCharge" && confirmation + ? cost.recurrence.kind === "everyNMonths" + ? compareMonths(monthOf(confirmation.date), cursor) > 0 + ? confirmation.date + : cursor + : confirmation.date + : cursor; const next = stepFrom(from, cost.recurrence); // The loop terminates only if the step strictly increases. `n >= 1` is @@ -138,13 +151,16 @@ export function occurrencesOf( if (!isBefore(cursor, next)) { // Two causes, and the message names both because they need different // fixes. With a valid `n >= 1` a CALENDAR series can never reach this: - // every kind adds at least one day. Only `lastCharge` can, and only when - // a confirmation is dated more than one step before the slot it claims. + // every kind adds at least one day. Only `lastCharge` can, and only for + // a day/week-granular kind whose confirmation is dated far enough + // before the slot it claims that stepping from it still lands at or + // before the cursor — `everyNMonths` never reaches this branch, because + // it never steps from a confirmation earlier than the cursor. throw new Error( `Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` + (confirmation ? `Purchase ${confirmation.id} claims that slot but is dated ${confirmation.date}, ` + - `more than one step earlier — correct its date to move the series forward.` + `which does not move the series past ${cursor} — correct its date so the series advances.` : `A recurrence must move strictly forward; check that n is at least 1.`), ); }