diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 235ebd4..26f02f3 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -35,31 +35,57 @@ const none = new Map(); describe("stepFrom", () => { test("everyNMonths steps whole months and stays month-granular", () => { - expect(stepFrom("2026-01", { kind: "everyNMonths", n: 1 })).toBe("2026-02"); - expect(stepFrom("2026-11", { kind: "everyNMonths", n: 3 })).toBe("2027-02"); + expect(stepFrom("2026-01", { kind: "everyNMonths", n: 1 }, "calendar")).toBe("2026-02"); + expect(stepFrom("2026-11", { kind: "everyNMonths", n: 3 }, "calendar")).toBe("2027-02"); }); - test("everyNMonths from a day-granular date returns the month", () => { - // Reachable under lastCharge anchoring, where the step runs from an actual - // purchase date. - expect(stepFrom("2026-01-17", { kind: "everyNMonths", n: 1 })).toBe("2026-02"); + test("everyNMonths from a day-granular date returns the month, under calendar", () => { + expect(stepFrom("2026-01-17", { kind: "everyNMonths", n: 1 }, "calendar")).toBe("2026-02"); }); test("everyNDays steps days", () => { - expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 })).toBe("2026-02-02"); - expect(stepFrom("2026-01-31", { kind: "everyNDays", n: 30 })).toBe("2026-03-02"); + expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 }, "calendar")).toBe("2026-02-02"); + expect(stepFrom("2026-01-31", { kind: "everyNDays", n: 30 }, "calendar")).toBe("2026-03-02"); }); test("everyNWeeks lands on the named weekday", () => { // 2026-09-03 is a Thursday (4). Two weeks on is 2026-09-17, also Thursday. - expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 })).toBe("2026-09-17"); + expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 }, "calendar")).toBe( + "2026-09-17", + ); }); test("everyNWeeks snaps forward when the cursor is off-weekday", () => { // Reachable under lastCharge: paid on a Saturday, but the series is Thursdays. // 2026-09-05 is a Saturday; +1 week is 2026-09-12 (Saturday), snapping // forward to the next Thursday gives 2026-09-17. - expect(stepFrom("2026-09-05", { kind: "everyNWeeks", n: 1, weekday: 4 })).toBe("2026-09-17"); + expect(stepFrom("2026-09-05", { kind: "everyNWeeks", n: 1, weekday: 4 }, "calendar")).toBe( + "2026-09-17", + ); + }); +}); + +describe("stepFrom, by anchoring", () => { + const monthly = { kind: "everyNMonths", n: 1 } as const; + + test("everyNMonths under calendar stays month-granular", () => { + expect(stepFrom("2026-09", monthly, "calendar")).toBe("2026-10"); + expect(stepFrom("2026-09-20", monthly, "calendar")).toBe("2026-10"); + }); + + test("everyNMonths under lastCharge is day-precise", () => { + // The whole point: a plan refreshed on the 20th is next due on the 20th. + expect(stepFrom("2026-09-20", monthly, "lastCharge")).toBe("2026-10-20"); + expect(stepFrom("2026-01-31", monthly, "lastCharge")).toBe("2026-02-28"); + }); + + test("day and week kinds ignore anchoring", () => { + for (const anchoring of ["calendar", "lastCharge"] as const) { + expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 }, anchoring)).toBe("2026-02-02"); + expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 }, anchoring)).toBe( + "2026-09-17", + ); + } }); }); @@ -207,10 +233,13 @@ describe("occurrencesOf — lastCharge anchoring: a monthly bill paid early (I3) 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. + // series rebases from there — and, per stepFrom's anchoring-aware step, + // stays day-precise from that point on, the same way a phone plan's cycle + // does: the confirmation is the first day-granular fact the series has + // ever had to go on, and once it does, it keeps it. 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"]); + expect(dates).toEqual(["2026-08", "2026-10-05", "2026-11-05"]); }); }); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index 3462c44..aebc581 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -1,6 +1,7 @@ -import { addDays, weekdayOf } from "./days.ts"; +import { addDays, addMonthsToDate, weekdayOf } from "./days.ts"; import { addMonths, compareMonths, monthOf } from "./months.ts"; import type { + Anchoring, Dataset, IsoDate, Money, @@ -50,15 +51,34 @@ function snapForward(date: IsoDate, weekday: number): IsoDate { /** * Where the next occurrence falls, given the one before it. * + * Takes the anchoring because `everyNMonths` means two different things: + * under `calendar` rent occurs IN a month and the grid is month-granular; + * under `lastCharge` a plan refreshed on the 20th is next due on the 20th, + * so the step is day-precise and clamps (`addMonthsToDate`). The other two + * kinds are day-granular either way and ignore the argument. + * + * `date !== monthOf(date)` under `lastCharge` is "this input actually carries + * a day" — true only once `occurrencesOf`'s walk has rebased from a real + * confirmation (`rebasedFrom`), because a month-only `startDate` (the + * documented shape for `everyNMonths`, whatever the anchoring) has no day to + * be precise about yet. A still-unconfirmed or paid-on-schedule `everyNMonths` + * cost stays month-granular under either anchoring for exactly that reason; + * it is the day-precise `Purchase.date` of an off-schedule confirmation that + * ever introduces a day in the first place. + * * Adding a recurrence kind means adding a case here and a variant to * `Recurrence`. Nothing else in the app switches on the kind. */ -export function stepFrom(date: IsoDate, recurrence: Recurrence): IsoDate { +export function stepFrom( + date: IsoDate, + recurrence: Recurrence, + anchoring: Anchoring, +): IsoDate { switch (recurrence.kind) { case "everyNMonths": - // Month-granular in, month-granular out. `monthOf` also accepts a - // day-granular date, which is what a lastCharge rebase supplies. - return addMonths(monthOf(date), recurrence.n); + return anchoring === "lastCharge" && date !== monthOf(date) + ? addMonthsToDate(date, recurrence.n) + : addMonths(monthOf(date), recurrence.n); case "everyNDays": return addDays(date, recurrence.n); case "everyNWeeks": @@ -80,6 +100,10 @@ export function confirmationsFor( const found = new Map(); for (const purchase of purchases) { if (purchase.source?.recurringId === recurringId) { + // A lastCharge purchase names no slot, so it cannot be keyed here. + // occurrencesOf is rewritten in Task 3 to walk lastCharge costs off + // Purchase.date directly instead of through this map. + if (!purchase.source.occurrenceDate) continue; // rewritten in Task 3 found.set(purchase.source.occurrenceDate, purchase); } } @@ -120,7 +144,10 @@ function rebasedFrom(cost: RecurringCost, cursor: IsoDate, confirmedDate: IsoDat * `recordedDate`, must not offer a row that would produce that pairing. */ export function wouldAdvancePast(cost: RecurringCost, slot: IsoDate, recordedDate: IsoDate): boolean { - return isBefore(slot, stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence)); + return isBefore( + slot, + stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence, cost.anchoring), + ); } /** @@ -171,7 +198,7 @@ export function occurrencesOf( // the same month simply does not rebase. Day/week-granular kinds keep // stepping from the confirmation's exact date, unchanged. const from = confirmation ? rebasedFrom(cost, cursor, confirmation.date) : cursor; - const next = stepFrom(from, cost.recurrence); + const next = stepFrom(from, cost.recurrence, cost.anchoring); // The loop terminates only if the step strictly increases. `n >= 1` is // enforced at both write boundaries, so reaching this is a bug rather than diff --git a/src/domain/types.ts b/src/domain/types.ts index 193c5e7..1b0c281 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -177,15 +177,15 @@ export interface Purchase { /** null means the whole total is charged in the month of `date`. */ schedule: Schedule | null; /** - * Present when this purchase confirms a projected occurrence. + * Present when this purchase is a charge for a recurring cost. * - * `occurrenceDate` is IDENTITY — the slot the projector generated, which - * this purchase claims so the slot stops being projected. `Purchase.date` is - * TRUTH — when the money moved. They differ whenever a bill is paid off - * schedule, and under `lastCharge` anchoring it is `date`, not - * `occurrenceDate`, that the next occurrence is measured from. + * `occurrenceDate` is the slot this purchase settles, and exists ONLY for a + * `calendar` cost, where a fixed grid of slots is what a payment settles. + * A `lastCharge` cost has no grid — its next due date is derived from this + * purchase's own `date` — so the field is absent there, and its presence + * would be a lie about how that schedule is computed. */ - source?: { recurringId: RecurringCostId; occurrenceDate: IsoDate }; + source?: { recurringId: RecurringCostId; occurrenceDate?: IsoDate }; } export interface FxRate { diff --git a/src/export/json.ts b/src/export/json.ts index 7ae743d..c0767de 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -428,7 +428,12 @@ export function parseDatasetJson(text: string): Dataset { `${label} names unknown recurring cost "${purchase.source.recurringId}"`, ); } - if (!PURCHASE_DATE.test(purchase.source.occurrenceDate)) { + // 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}"`, ); diff --git a/src/store/actions.ts b/src/store/actions.ts index 59093ac..99ad6d2 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -251,6 +251,9 @@ function requireConfirmationAdvances( if (!source) return; const cost = draft.recurring.find((c) => c.id === source.recurringId); if (!cost) return; + // A lastCharge purchase names no slot to check advancement against — this + // whole check is calendar-only until Task 5 rewrites it for both walks. + if (!source.occurrenceDate) return; // rewritten in Task 5 if (wouldAdvancePast(cost, source.occurrenceDate, date)) return; throw new Error( `Purchase ${purchase.id} confirms "${cost.name}" for ${source.occurrenceDate}, and ` +