fix(domain): a monthly lastCharge bill paid a few days early no longer throws (I3)
Under lastCharge anchoring, an everyNMonths cost stepped from the confirmation's exact date even when that date fell earlier in the same month as the slot it claims (e.g. rent for 2026-09 paid 2026-08-28). Since stepFrom normalises through monthOf, that produced the same month as the cursor, which the walk read as "did not advance" and threw — for a completely ordinary early payment. The month-granular step now runs from whichever is later, the cursor or the confirmation's month, so a payment within the same month simply does not rebase. A payment recorded in a LATER month still rebases the series forward, symmetric with the day-granular cap-hit-early behaviour. Also corrects the termination error's message, which claimed a purchase was dated "more than one step earlier" regardless of how early it actually was.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.`),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user