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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"`,
|
||||
);
|
||||
|
||||
@@ -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 ` +
|
||||
|
||||
Reference in New Issue
Block a user