feat(types): occurrenceDate is optional, stepFrom takes anchoring

A lastCharge cost has no grid of slots, so a purchase against one settles
nothing — its own date is the whole story. occurrenceDate stays required for
calendar, where a slot is exactly what a payment settles.

stepFrom needs the anchoring because everyNMonths means two different things:
a month-granular grid under calendar, a day-precise clamped step under
lastCharge. The lastCharge branch only takes the day-precise path when its
input actually carries a day (date !== monthOf(date)) — a still-unconfirmed
or paid-on-schedule everyNMonths cost has no day to be precise about yet
(its startDate is documented as month-only), and without this guard the
existing walk crashes calling addMonthsToDate on a bare month. This also
means an off-schedule confirmation that rebases the series now correctly
stays day-precise afterward, which updates one pre-existing occurrencesOf
expectation (I3's "paying after the month" case) to match.

Three call sites read source.occurrenceDate as definite and are rewritten
properly in later tasks (3, 5, 6); each gets a narrow scaffolding guard
with a `// rewritten in Task N` comment rather than a cast or `!`.
This commit is contained in:
Lucas Winther
2026-09-05 01:03:57 +02:00
parent 9035feed5f
commit f8591fdc57
5 changed files with 91 additions and 27 deletions
+41 -12
View File
@@ -35,31 +35,57 @@ const none = new Map<string, Purchase>();
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"]);
});
});
+34 -7
View File
@@ -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<IsoDate, Purchase>();
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
+7 -7
View File
@@ -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 {
+6 -1
View File
@@ -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}"`,
);
+3
View File
@@ -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 ` +