feat(domain): anchored costs have no slots
A lastCharge cost's next due date is one cycle after the latest actual charge. So it can be charged any number of times per cycle, each charge restarts the clock, and the reported bug is gone: two payments in September leave the next due in October, not November. calendar is untouched and keeps its grid. Paying September AND October's rent really does mean nothing is due until November — the bug was only ever a bug for the anchored case. occurrencesOf's second parameter changes from a confirmations Map to the raw purchase list: the anchored walk needs chargesFor's view (every Purchase.date for the cost, latest wins), the grid walk needs confirmationsFor's view (purchases keyed by the slot they settle), and only occurrencesOf itself knows which to build. occurrencesByMonth now passes dataset.purchases straight through instead of pre-building a map. stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task 2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored walk's cursor is always day-granular now (startDate or a real charge date), so the condition was dead; if a month-only date ever reaches that branch, addMonthsToDate throwing is correct, not a bug to paper over. confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding guard (also Task 2) is now a thrown error instead: the function is calendar- only, a lastCharge charge should never reach it, and continuing silently would hide a programming error as a slot that merely looks unpaid. DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the tests that existed only to exercise them (6 in occurrences.test.ts describing the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the series" suppression, 5 in actions.test.ts exercising requireConfirmationAdvances's refusal and its pass-throughs — see each file's CONTROLLER RULING comment for the line-by-line reasoning). All three existed to stop a confirmation landing behind the slot it claims; with no slots on the anchored path and no rebasing on the grid path, that situation cannot arise any more. The anchored walk's own termination throw stays, as a defensive assert against n < 1 — never softened into a break. ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code removed (the X1 condition and requireConfirmationAdvances respectively) so this task's suite would compile and run; Task 6/7 own reintroducing whatever anchored-model-correct guard, if any, replaces them. This removes more code than it adds.
This commit is contained in:
+150
-113
@@ -18,6 +18,7 @@ function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
||||
};
|
||||
}
|
||||
|
||||
/** A calendar charge — it settles the slot named by `occurrenceDate`. */
|
||||
function confirmation(occurrenceDate: string, paidOn: string, id = "p1"): Purchase {
|
||||
return {
|
||||
id,
|
||||
@@ -31,7 +32,7 @@ function confirmation(occurrenceDate: string, paidOn: string, id = "p1"): Purcha
|
||||
};
|
||||
}
|
||||
|
||||
const none = new Map<string, Purchase>();
|
||||
const none: Purchase[] = [];
|
||||
|
||||
describe("stepFrom", () => {
|
||||
test("everyNMonths steps whole months and stays month-granular", () => {
|
||||
@@ -121,7 +122,7 @@ describe("occurrencesOf — calendar anchoring", () => {
|
||||
});
|
||||
|
||||
test("a confirmation does NOT move a calendar-anchored series", () => {
|
||||
const paidLate = new Map([["2026-01", confirmation("2026-01", "2026-01-28")]]);
|
||||
const paidLate = [confirmation("2026-01", "2026-01-28")];
|
||||
const dates = occurrencesOf(cost(), paidLate, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02", "2026-03"]);
|
||||
});
|
||||
@@ -134,9 +135,9 @@ describe("occurrencesOf — calendar anchoring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — confirmation", () => {
|
||||
describe("occurrencesOf — confirmation (calendar)", () => {
|
||||
test("a confirmed slot carries the purchase id", () => {
|
||||
const claimed = new Map([["2026-02", confirmation("2026-02", "2026-02", "pX")]]);
|
||||
const claimed = [confirmation("2026-02", "2026-02", "pX")];
|
||||
const found = occurrencesOf(cost(), claimed, "2026-03");
|
||||
expect(found.map((o) => o.confirmedBy)).toEqual([null, "pX", null]);
|
||||
});
|
||||
@@ -144,122 +145,26 @@ describe("occurrencesOf — confirmation", () => {
|
||||
test("a slot is claimed by occurrenceDate, not by the purchase's own date", () => {
|
||||
// Paid on the 28th for the slot dated 2026-01. The slot is claimed; a new
|
||||
// one does not appear.
|
||||
const claimed = new Map([["2026-01", confirmation("2026-01", "2026-01-28", "pY")]]);
|
||||
const claimed = [confirmation("2026-01", "2026-01-28", "pY")];
|
||||
const found = occurrencesOf(cost(), claimed, "2026-02");
|
||||
expect(found[0]!.confirmedBy).toBe("pY");
|
||||
expect(found[1]!.confirmedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — lastCharge anchoring: the phone bill", () => {
|
||||
const phone = cost({
|
||||
id: "r1",
|
||||
name: "Phone",
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
|
||||
test("unconfirmed, it behaves exactly like a calendar series", () => {
|
||||
// Each occurrence steps from its own projected date: the projection assumes
|
||||
// bills are paid on time.
|
||||
const dates = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
|
||||
test("hitting the data cap early rebases the whole series", () => {
|
||||
// The slot sits at 2026-01-31. The cap is hit on the 12th, so the owner
|
||||
// confirms THAT slot with a purchase dated 2026-01-12. The next occurrence
|
||||
// is 30 days after the 12th, not after the 31st.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
});
|
||||
|
||||
test("the cap can be hit twice", () => {
|
||||
const twice = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
["2026-02-11", confirmation("2026-02-11", "2026-01-20", "pC")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, twice, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-02-19", "2026-03-21"]);
|
||||
});
|
||||
|
||||
test("deleting the confirming purchase un-confirms the slot and restores the series", () => {
|
||||
// CONTROLLER RULING: the brief's original version of this test was
|
||||
// byte-identical to "unconfirmed, it behaves exactly like a calendar
|
||||
// series" above and asserted nothing new. Rewritten to actually contrast
|
||||
// the two states: walk WITH the capHit confirmations (rebased dates),
|
||||
// then walk the SAME cost with an empty map (as if the confirming
|
||||
// purchase had been deleted) and assert the dates differ and match the
|
||||
// plain unconfirmed series.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const rebased = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(rebased).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
|
||||
// Deleting the confirming purchase leaves no entry in the confirmations
|
||||
// map for that slot — this is what an empty map models here, since the
|
||||
// slot in question (2026-01-31) is the only one whose confirmation
|
||||
// affects downstream dates.
|
||||
const afterDelete = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(afterDelete).not.toEqual(rebased);
|
||||
expect(afterDelete).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
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 — 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-05", "2026-11-05"]);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// step runs from the confirmation's own date, so a payment recorded more
|
||||
// than n days before the slot it claims would move the cursor backwards.
|
||||
// A visible error beats a fold that silently emits duplicate months.
|
||||
const phone = cost({
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
const outOfOrder = new Map([
|
||||
["2026-01-31", confirmation("2026-01-31", "2025-11-02", "pBad")],
|
||||
]);
|
||||
expect(() => occurrencesOf(phone, outOfOrder, "2026-03")).toThrow(/did not advance/);
|
||||
});
|
||||
|
||||
// CONTROLLER RULING: the round-1 version of this block also had "a
|
||||
// confirmation dated absurdly early throws rather than walking backwards",
|
||||
// which modelled the OLD lastCharge slot-rebasing mechanism (a confirmation
|
||||
// map entry pre-claiming a future slot on a wildly early date). That shape
|
||||
// does not exist any more: a lastCharge charge is just a Purchase.date, and
|
||||
// a charge dated before a cost's startDate is now clamped forward to
|
||||
// startDate rather than accepted and walked from (see "a charge back-dated
|
||||
// before startDate cannot pull the series backwards" below) — so the throw
|
||||
// that test asserted is no longer reachable by the scenario it built.
|
||||
// Deleted rather than adapted, because there is no reachable input left that
|
||||
// exercises the same path; the anchored walk's own termination throw is
|
||||
// still covered by the two tests kept below, which only need n < 1.
|
||||
test("a recurrence that cannot advance throws rather than hanging", () => {
|
||||
const broken = cost({ recurrence: { kind: "everyNDays", n: 0 }, startDate: "2026-01-01" });
|
||||
expect(() => occurrencesOf(broken, none, "2026-02")).toThrow(/did not advance/);
|
||||
@@ -314,3 +219,135 @@ describe("occurrencesByMonth", () => {
|
||||
expect(byMonth.has("2026-02")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the anchored walk (lastCharge)", () => {
|
||||
function phone(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
||||
return cost({
|
||||
id: "phone",
|
||||
name: "Phone plan",
|
||||
startDate: "2026-09-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "lastCharge",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
/** A charge against a cost carries NO occurrenceDate — there is no slot. */
|
||||
function charge(id: string, date: string, recurringId = "phone"): Purchase {
|
||||
return {
|
||||
id, date, description: "Phone plan",
|
||||
total: { amount: 300, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId },
|
||||
};
|
||||
}
|
||||
|
||||
test("with no charges, the first occurrence is the start date", () => {
|
||||
expect(occurrencesOf(phone(), [], "2026-11").map((o) => o.date)).toEqual([
|
||||
"2026-09-01", "2026-10-01", "2026-11-01",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a charge re-anchors the series to its own date", () => {
|
||||
const dates = occurrencesOf(phone(), [charge("p1", "2026-09-20")], "2026-11").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-10-20", "2026-11-20"]);
|
||||
});
|
||||
|
||||
test("THE REPORTED BUG: two charges in one month leave the next due one cycle after the LATER", () => {
|
||||
const dates = occurrencesOf(
|
||||
phone(),
|
||||
[charge("p1", "2026-09-05"), charge("p2", "2026-09-20")],
|
||||
"2026-11",
|
||||
).map((o) => o.date);
|
||||
// NOT 2026-11-xx. Two payments in September do not buy two months.
|
||||
expect(dates[0]).toBe("2026-10-20");
|
||||
});
|
||||
|
||||
test("three charges in one month — the cap can be hit as often as it likes", () => {
|
||||
const dates = occurrencesOf(
|
||||
phone(),
|
||||
[charge("p1", "2026-09-05"), charge("p2", "2026-09-12"), charge("p3", "2026-09-28")],
|
||||
"2026-11",
|
||||
).map((o) => o.date);
|
||||
expect(dates[0]).toBe("2026-10-28");
|
||||
});
|
||||
|
||||
test("charges are ordered by date, not by array position", () => {
|
||||
const dates = occurrencesOf(
|
||||
phone(),
|
||||
[charge("p2", "2026-09-20"), charge("p1", "2026-09-05")],
|
||||
"2026-10",
|
||||
).map((o) => o.date);
|
||||
expect(dates[0]).toBe("2026-10-20");
|
||||
});
|
||||
|
||||
test("a charge back-dated before startDate cannot pull the series backwards", () => {
|
||||
const dates = occurrencesOf(phone(), [charge("p1", "2026-06-01")], "2026-11").map((o) => o.date);
|
||||
// Anchored at startDate, not at the stray charge.
|
||||
expect(dates[0]).toBe("2026-10-01");
|
||||
});
|
||||
|
||||
test("every anchored occurrence is unconfirmed — a charge is a Purchase, not an occurrence", () => {
|
||||
const found = occurrencesOf(phone(), [charge("p1", "2026-09-20")], "2026-11");
|
||||
expect(found.every((o) => o.confirmedBy === null)).toBe(true);
|
||||
});
|
||||
|
||||
test("a charge for a DIFFERENT cost does not re-anchor this one", () => {
|
||||
const dates = occurrencesOf(phone(), [charge("p1", "2026-09-20", "other")], "2026-10").map((o) => o.date);
|
||||
expect(dates[0]).toBe("2026-09-01");
|
||||
});
|
||||
|
||||
test("endedFrom still stops the series", () => {
|
||||
const dates = occurrencesOf(phone({ endedFrom: "2026-10" }), [], "2026-12").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-09-01"]);
|
||||
});
|
||||
|
||||
test("a day-granular anchored cost steps in days", () => {
|
||||
const daily = phone({ recurrence: { kind: "everyNDays", n: 30 }, startDate: "2026-09-01" });
|
||||
const dates = occurrencesOf(daily, [charge("p1", "2026-09-12")], "2026-11").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-10-12", "2026-11-11"]);
|
||||
});
|
||||
|
||||
test("unconfirmed across many months, a day-granular anchored cost just keeps stepping", () => {
|
||||
// CONTROLLER RULING: replaces round-1's "unconfirmed, it behaves exactly
|
||||
// like a calendar series" (old describe "lastCharge anchoring: the phone
|
||||
// bill"). That test's OTHER three siblings modelled the old slot-based
|
||||
// rebasing (a Map keyed by occurrenceDate, pre-confirming a future slot)
|
||||
// and were deleted outright — that mechanism no longer exists. This one
|
||||
// test's own scenario (no charges at all) survives unchanged in meaning:
|
||||
// with nothing to re-anchor from, the anchored walk is just startDate
|
||||
// stepped forward by the recurrence, same as it always was.
|
||||
const daily = cost({
|
||||
id: "phone", name: "Phone", startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 }, anchoring: "lastCharge",
|
||||
});
|
||||
const dates = occurrencesOf(daily, [], "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the calendar walk is unchanged", () => {
|
||||
test("a purchase settles the slot it names, and two settle two", () => {
|
||||
const rent = cost({ id: "rent", startDate: "2026-09", anchoring: "calendar" });
|
||||
/** A calendar charge DOES carry a slot — that is the difference. */
|
||||
function settles(id: string, slot: string, paidOn: string): Purchase {
|
||||
return {
|
||||
id, date: paidOn, description: "Rent",
|
||||
total: { amount: 8200, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId: "rent", occurrenceDate: slot },
|
||||
};
|
||||
}
|
||||
const found = occurrencesOf(
|
||||
rent,
|
||||
[settles("p1", "2026-09", "2026-09-05"), settles("p2", "2026-10", "2026-09-20")],
|
||||
"2026-11",
|
||||
);
|
||||
// For a calendar bill this IS correct: paying September and October's
|
||||
// rent really does mean nothing is due until November.
|
||||
expect(found.map((o) => o.confirmedBy)).toEqual(["p1", "p2", null]);
|
||||
});
|
||||
});
|
||||
|
||||
+126
-87
@@ -52,19 +52,19 @@ 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.
|
||||
* under `calendar` rent occurs IN a month and the grid is month-granular, so
|
||||
* the step runs on `MonthId` arithmetic (`addMonths`/`monthOf`); 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.
|
||||
* The anchored walk (`anchoredOccurrences`) only ever calls this with a
|
||||
* day-granular `date`: its cursor starts at `cost.startDate` or at a real
|
||||
* charge's date, and an anchored `everyNMonths` cost is required to carry a
|
||||
* day-granular `startDate` for exactly this reason. If a month-only date ever
|
||||
* reaches this branch, `addMonthsToDate` throwing is CORRECT — something
|
||||
* upstream produced a cursor this function was never meant to receive, and a
|
||||
* visible error beats silently treating "2026-09" as "2026-09-01".
|
||||
*
|
||||
* Adding a recurrence kind means adding a case here and a variant to
|
||||
* `Recurrence`. Nothing else in the app switches on the kind.
|
||||
@@ -76,7 +76,7 @@ export function stepFrom(
|
||||
): IsoDate {
|
||||
switch (recurrence.kind) {
|
||||
case "everyNMonths":
|
||||
return anchoring === "lastCharge" && date !== monthOf(date)
|
||||
return anchoring === "lastCharge"
|
||||
? addMonthsToDate(date, recurrence.n)
|
||||
: addMonths(monthOf(date), recurrence.n);
|
||||
case "everyNDays":
|
||||
@@ -87,11 +87,23 @@ export function stepFrom(
|
||||
}
|
||||
|
||||
/**
|
||||
* The purchases confirming one cost, keyed by the slot each one claims.
|
||||
* The purchases confirming one `calendar` cost, keyed by the slot each one
|
||||
* claims.
|
||||
*
|
||||
* Keyed by `source.occurrenceDate` rather than by `Purchase.date`, because the
|
||||
* slot is what a confirmation claims. The two differ whenever a bill is paid
|
||||
* off schedule.
|
||||
*
|
||||
* Calendar-only. A `lastCharge` cost has no slots (`Purchase.source`'s doc in
|
||||
* types.ts) — its charges are walked by `chargesFor`/`anchoredOccurrences`
|
||||
* off `Purchase.date` directly, and never reach this function. A purchase
|
||||
* that DOES reach here (its `recurringId` matches) but carries no
|
||||
* `occurrenceDate` is therefore not a data problem to skip past quietly: it
|
||||
* means some caller attached a `lastCharge`-shaped purchase (or one with a
|
||||
* dangling/mismatched source) to a cost this function is walking as
|
||||
* `calendar`, which is a programming error upstream. Throwing says so
|
||||
* directly instead of the old scaffolding `continue`, which would have
|
||||
* silently dropped the confirmation and left the slot looking unpaid.
|
||||
*/
|
||||
export function confirmationsFor(
|
||||
purchases: readonly Purchase[],
|
||||
@@ -99,71 +111,118 @@ export function confirmationsFor(
|
||||
): Map<IsoDate, Purchase> {
|
||||
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);
|
||||
if (purchase.source?.recurringId !== recurringId) continue;
|
||||
if (!purchase.source.occurrenceDate) {
|
||||
throw new Error(
|
||||
`Purchase ${purchase.id} confirms recurring cost ${recurringId} but carries no ` +
|
||||
`occurrenceDate. confirmationsFor is for the calendar walk only, where every charge ` +
|
||||
`names the slot it settles — a lastCharge charge must never reach here.`,
|
||||
);
|
||||
}
|
||||
found.set(purchase.source.occurrenceDate, purchase);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The date `occurrencesOf`'s walk would step from after confirming `cost`'s
|
||||
* slot `cursor` as having been paid on `confirmedDate` — the same rebase rule
|
||||
* the walk applies inline. Extracted so a caller can ask "would this pairing
|
||||
* advance the series" (see `wouldAdvancePast` below) without re-deriving it.
|
||||
*/
|
||||
function rebasedFrom(cost: RecurringCost, cursor: IsoDate, confirmedDate: IsoDate): IsoDate {
|
||||
if (cost.anchoring !== "lastCharge") return cursor;
|
||||
if (cost.recurrence.kind === "everyNMonths") {
|
||||
return compareMonths(monthOf(confirmedDate), cursor) > 0 ? confirmedDate : cursor;
|
||||
}
|
||||
return confirmedDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether recording `cost`'s slot `slot` as paid on `recordedDate` would
|
||||
* leave `occurrencesOf` able to step strictly past `slot` — i.e. whether that
|
||||
* pairing is one the walk can survive at all, checked BEFORE a purchase
|
||||
* carrying it is ever written.
|
||||
* Every purchase recorded as a charge for one cost, oldest first.
|
||||
*
|
||||
* Calendar anchoring always advances, because it never rebases from the
|
||||
* confirmation date; `everyNMonths` under `lastCharge` also always advances,
|
||||
* because it only rebases forward within the slot's own month. The one case
|
||||
* that can fail is a day/week-granular `lastCharge` cost recorded far enough
|
||||
* before its slot that stepping from `recordedDate` still lands at or before
|
||||
* `slot` — exactly the shape of `occurrencesOf`'s "did not advance" throw.
|
||||
*
|
||||
* Exported for `ExpectedBand.tsx`'s "Coming up" group, whose whole premise is
|
||||
* recording a slot's confirmation on a date OTHER than the slot itself
|
||||
* (today, because paying early). The walk's throw is correct — a step that
|
||||
* lands backwards really is bad data — so the band, which chooses
|
||||
* `recordedDate`, must not offer a row that would produce that pairing.
|
||||
* The anchored walk needs the LATEST of these and nothing else: a charge is
|
||||
* not a confirmation of anything, it is the event that restarts the cycle.
|
||||
*/
|
||||
export function wouldAdvancePast(cost: RecurringCost, slot: IsoDate, recordedDate: IsoDate): boolean {
|
||||
return isBefore(
|
||||
slot,
|
||||
stepFrom(rebasedFrom(cost, slot, recordedDate), cost.recurrence, cost.anchoring),
|
||||
);
|
||||
export function chargesFor(
|
||||
purchases: readonly Purchase[],
|
||||
recurringId: RecurringCostId,
|
||||
): Purchase[] {
|
||||
return purchases
|
||||
.filter((p) => p.source?.recurringId === recurringId)
|
||||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of one cost from its start through `upToMonth`, in order.
|
||||
*
|
||||
* A walk rather than a per-month query, because under `lastCharge` anchoring
|
||||
* where the series sits in a given month depends on every confirmation before
|
||||
* it. The walk always begins at `startDate` even when that precedes the fold
|
||||
* start, because the phase depends on the whole history; callers read only the
|
||||
* months they need. The cost is trivial — thirty-five years of a 28-day cycle
|
||||
* is about thirteen thousand integer additions.
|
||||
* Two walks, one per anchoring, sharing only `stepFrom`.
|
||||
*
|
||||
* `calendar` is a fixed grid: slots from `startDate`, each settled or not by
|
||||
* the purchase naming it. Nothing a payment does moves the grid, so paying
|
||||
* September AND October's rent correctly leaves nothing due until November.
|
||||
*
|
||||
* `lastCharge` has no grid. Its next due date is one cycle after the latest
|
||||
* actual charge, so a bill can be charged any number of times per cycle and
|
||||
* each charge restarts the clock — which is what a phone plan that refreshes
|
||||
* when its data runs out actually does. Every occurrence it emits is
|
||||
* unconfirmed by definition: a charge that happened is a `Purchase`, not an
|
||||
* occurrence.
|
||||
*
|
||||
* `upToMonth` comes from the caller. This module reads no clock, which is what
|
||||
* keeps `src/domain/` pure.
|
||||
*/
|
||||
export function occurrencesOf(
|
||||
cost: RecurringCost,
|
||||
purchases: readonly Purchase[],
|
||||
upToMonth: MonthId,
|
||||
): Occurrence[] {
|
||||
return cost.anchoring === "lastCharge"
|
||||
? anchoredOccurrences(cost, purchases, upToMonth)
|
||||
: gridOccurrences(cost, confirmationsFor(purchases, cost.id), upToMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `lastCharge` walk. No slots, no rebasing rule to apply — just "one cycle
|
||||
* after the latest charge, or `startDate` if there has never been one".
|
||||
*/
|
||||
function anchoredOccurrences(
|
||||
cost: RecurringCost,
|
||||
purchases: readonly Purchase[],
|
||||
upToMonth: MonthId,
|
||||
): Occurrence[] {
|
||||
const charges = chargesFor(purchases, cost.id);
|
||||
const latest = charges.length > 0 ? charges[charges.length - 1]!.date : null;
|
||||
|
||||
// `max(latest, startDate)`: a charge back-dated before the cost existed must
|
||||
// not pull the series behind its own start.
|
||||
const anchor = latest === null ? null : isBefore(latest, cost.startDate) ? cost.startDate : latest;
|
||||
|
||||
let cursor = anchor === null ? cost.startDate : stepFrom(anchor, cost.recurrence, cost.anchoring);
|
||||
|
||||
const found: Occurrence[] = [];
|
||||
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
|
||||
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
|
||||
|
||||
found.push({
|
||||
recurringId: cost.id,
|
||||
date: cursor,
|
||||
amount: cost.amount,
|
||||
// Always null: an anchored occurrence is a projection, and a charge that
|
||||
// happened is a Purchase counted in `charges`, never an occurrence here.
|
||||
confirmedBy: null,
|
||||
});
|
||||
|
||||
const next = stepFrom(cursor, cost.recurrence, cost.anchoring);
|
||||
// Defensive. Stepping forward from a real date with `n >= 1` always
|
||||
// advances, so this should be unreachable — but a hang is the one failure
|
||||
// worse than a visible error, and both write boundaries enforce n >= 1.
|
||||
if (!isBefore(cursor, next)) {
|
||||
throw new Error(
|
||||
`Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` +
|
||||
`A recurrence must move strictly forward; check that n is at least 1.`,
|
||||
);
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `calendar` walk: a fixed grid of slots from `startDate`, stepping
|
||||
* forward by `stepFrom` regardless of when (or whether) each slot was paid.
|
||||
*
|
||||
* Unlike the anchored walk, a confirmation never moves this cursor — paying
|
||||
* September AND October's rent correctly leaves nothing due until November,
|
||||
* which is exactly why `calendar` was never the buggy case.
|
||||
*/
|
||||
function gridOccurrences(
|
||||
cost: RecurringCost,
|
||||
confirmations: ReadonlyMap<IsoDate, Purchase>,
|
||||
upToMonth: MonthId,
|
||||
@@ -186,19 +245,10 @@ export function occurrencesOf(
|
||||
confirmedBy: confirmation?.id ?? null,
|
||||
});
|
||||
|
||||
// `lastCharge` measures from when the money ACTUALLY moved, which is what
|
||||
// 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 = confirmation ? rebasedFrom(cost, cursor, confirmation.date) : cursor;
|
||||
const next = stepFrom(from, cost.recurrence, cost.anchoring);
|
||||
// No rebasing: the grid never moves off a confirmation's date. (The
|
||||
// `rebasedFrom` this used to call through returned `cursor` unchanged for
|
||||
// `calendar` anyway, so dropping it changes nothing here.)
|
||||
const next = stepFrom(cursor, 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
|
||||
@@ -206,19 +256,9 @@ export function occurrencesOf(
|
||||
// error. Never soften this into a break: silently dropping a bill would
|
||||
// put a wrong number on the screen, which AGENTS.md forbids outright.
|
||||
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 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}, ` +
|
||||
`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.`),
|
||||
`A recurrence must move strictly forward; check that n is at least 1.`,
|
||||
);
|
||||
}
|
||||
cursor = next;
|
||||
@@ -238,8 +278,7 @@ export function occurrencesByMonth(
|
||||
const byMonth = new Map<MonthId, Occurrence[]>();
|
||||
|
||||
for (const cost of dataset.recurring) {
|
||||
const confirmations = confirmationsFor(dataset.purchases, cost.id);
|
||||
for (const occurrence of occurrencesOf(cost, confirmations, upToMonth)) {
|
||||
for (const occurrence of occurrencesOf(cost, dataset.purchases, upToMonth)) {
|
||||
const month = monthOf(occurrence.date);
|
||||
const existing = byMonth.get(month);
|
||||
if (existing) existing.push(occurrence);
|
||||
|
||||
+12
-98
@@ -821,101 +821,15 @@ describe("confirmOccurrence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirming a slot writes an ORDINARY purchase, and the ordinary purchase
|
||||
* dialog can then edit its date — an action with nothing on its face to do
|
||||
* with recurring costs. Under `lastCharge` anchoring that date is what the
|
||||
* walk measures the next occurrence from, so moving it back far enough makes
|
||||
* the walk step backwards and throw out of `foldBalances`, taking every month
|
||||
* route, the year view and the summary down together. The write is refused
|
||||
* here so that state cannot be reached.
|
||||
*/
|
||||
describe("editing a confirmation's date", () => {
|
||||
function carDraft(anchoring: "calendar" | "lastCharge" = "lastCharge"): Dataset {
|
||||
const data = createSeedDataset("2026-08");
|
||||
data.posts[0]!.id = "housing";
|
||||
actions.addRecurringCost(data, {
|
||||
name: "Car insurance",
|
||||
archived: false,
|
||||
amount: { amount: 900, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring,
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The walk every month route, the year view and the summary go through. */
|
||||
function folds(data: Dataset): () => unknown {
|
||||
return () => occurrencesByMonth(data, "2026-12");
|
||||
}
|
||||
|
||||
function dateOf(data: Dataset, purchaseId: string): string {
|
||||
return data.purchases.find((p) => p.id === purchaseId)!.date;
|
||||
}
|
||||
|
||||
test("a date more than one cycle before the slot it claims is refused", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
expect(folds(data)).not.toThrow();
|
||||
|
||||
expect(() => actions.updatePurchase(data, purchase.id, { date: "2026-08-01" })).toThrow(
|
||||
/Car insurance/,
|
||||
);
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-09-13");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a date that keeps the series advancing is still allowed", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-09-08" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-09-08");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a purchase with no slot is an ordinary purchase and moves freely", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-13",
|
||||
description: "Sofa",
|
||||
total: { amount: 100, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-01-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-01-01");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a calendar-anchored cost's confirmation moves freely, because it never rebases", () => {
|
||||
const data = carDraft("calendar");
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
|
||||
expect(folds(data)).not.toThrow();
|
||||
});
|
||||
|
||||
test("a slot naming a cost that no longer exists does not block the edit", () => {
|
||||
const data = carDraft();
|
||||
const purchase = actions.confirmOccurrence(data, data.recurring[0]!.id, "2026-09-13", {
|
||||
date: "2026-09-13",
|
||||
});
|
||||
data.recurring = [];
|
||||
|
||||
actions.updatePurchase(data, purchase.id, { date: "2026-08-01" });
|
||||
expect(dateOf(data, purchase.id)).toBe("2026-08-01");
|
||||
});
|
||||
});
|
||||
// CONTROLLER RULING (Task 3): the entire "editing a confirmation's date"
|
||||
// block that used to live here (5 tests plus its own docstring) tested
|
||||
// `requireConfirmationAdvances`, deleted in this task alongside
|
||||
// `wouldAdvancePast`/`rebasedFrom` in `src/domain/occurrences.ts` — see that
|
||||
// commit. An anchored occurrence has no slot for an edited purchase date to
|
||||
// land "behind", so there is nothing left for that guard to refuse: every
|
||||
// case the deleted tests distinguished (a date that keeps the series
|
||||
// advancing, a purchase with no slot, a calendar-anchored purchase, a
|
||||
// dangling recurring-cost reference) now behaves identically — the edit
|
||||
// always succeeds — because the anchored walk always re-derives its cursor
|
||||
// from whichever charge is latest, never from a `Purchase.date` a caller is
|
||||
// trying to move "behind" a slot that no longer exists.
|
||||
|
||||
@@ -3,7 +3,6 @@ import { compareMonths, monthOf } from "../domain/months.ts";
|
||||
import { toDayOrdinal } from "../domain/days.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts";
|
||||
import { wouldAdvancePast } from "../domain/occurrences.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
@@ -221,61 +220,12 @@ export function addPurchase(
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation's `date` is TRUTH, and under `lastCharge` anchoring it is
|
||||
* what `occurrencesOf` measures the NEXT occurrence from. Moved back far
|
||||
* enough, the step from it lands at or before the slot the purchase claims,
|
||||
* and the walk throws its "did not advance" error — out of `foldBalances`, so
|
||||
* every month route, the year view and the summary go down at once, from an
|
||||
* edit made in the ordinary purchase dialog that never mentioned recurring
|
||||
* costs. Refusing the write is the only place that state can be prevented:
|
||||
* the walk's throw is correct and must not be softened, and by the time it
|
||||
* fires the bad data is already saved.
|
||||
*
|
||||
* `wouldAdvancePast` answers exactly this question and is what `ExpectedBand`
|
||||
* asks before OFFERING a row; both must agree with the walk, so neither
|
||||
* re-derives the condition.
|
||||
*
|
||||
* Two deliberate pass-throughs. A purchase with no slot is an ordinary
|
||||
* purchase and is not checked at all — this is the hot path for every
|
||||
* ordinary edit. A slot naming a cost that no longer exists is not checked
|
||||
* either: a dangling source is a separate concern, and refusing here would
|
||||
* only strand the purchase.
|
||||
*/
|
||||
function requireConfirmationAdvances(
|
||||
draft: Dataset,
|
||||
purchase: Purchase,
|
||||
source: Purchase["source"],
|
||||
date: IsoDate,
|
||||
): void {
|
||||
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 ` +
|
||||
`dating it ${date} would not move the series past that occurrence — ` +
|
||||
`pick a later date, or delete the purchase to un-confirm the occurrence`,
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePurchase(
|
||||
draft: Dataset,
|
||||
purchaseId: PurchaseId,
|
||||
changes: Partial<Omit<Purchase, "id">>,
|
||||
): void {
|
||||
const purchase = requirePurchase(draft, purchaseId);
|
||||
if (changes.date !== undefined && changes.date !== purchase.date) {
|
||||
requireConfirmationAdvances(
|
||||
draft,
|
||||
purchase,
|
||||
"source" in changes ? changes.source : purchase.source,
|
||||
changes.date,
|
||||
);
|
||||
}
|
||||
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
|
||||
if (changes.total) {
|
||||
resolved.total = roundMoneyValue(draft, changes.total);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx";
|
||||
import { occurrencesByMonth, wouldAdvancePast } from "../../domain/occurrences.ts";
|
||||
import { occurrencesByMonth } from "../../domain/occurrences.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts";
|
||||
@@ -109,15 +109,20 @@ describe("expectedGroups (Coming up)", () => {
|
||||
// sits in March because rent-like accounting isn't in play here — a
|
||||
// monthly cost's only occurrence for a month IS next month once this
|
||||
// month's is confirmed.
|
||||
//
|
||||
// CONTROLLER RULING (Task 3): an anchored `everyNMonths` cost now requires
|
||||
// a day-granular `startDate` and steps day-precisely (`addMonthsToDate`),
|
||||
// so this fixture and its charge use "-01" days rather than month-only
|
||||
// dates — a month-only `startDate` on a lastCharge cost now throws.
|
||||
const data = baseDataset();
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge", startDate: "2026-01-01" })];
|
||||
data.purchases = [confirmed("phone", "2026-02-01", "p1")]; // this month's is done
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.length).toBe(1);
|
||||
expect(groups.comingUp[0]!.recurringId).toBe("phone");
|
||||
expect(groups.comingUp[0]!.date).toBe("2026-03");
|
||||
expect(groups.comingUp[0]!.date).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
test("at most one row per cost, even when several future occurrences fall within the horizon", () => {
|
||||
@@ -228,17 +233,20 @@ describe("expectedGroups — X3 (a cost with an outstanding slot never also show
|
||||
// always safe to confirm early (X1 never suppresses `everyNMonths`), so
|
||||
// this isolates X3's own effect: suppressed while this month is
|
||||
// outstanding, offered again once it is settled.
|
||||
//
|
||||
// CONTROLLER RULING (Task 3): day-granular dates throughout — see the
|
||||
// comment on the "coming up" test above.
|
||||
const data = baseDataset();
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge", startDate: "2026-01-01" })];
|
||||
|
||||
let groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]);
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-02-01"]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
|
||||
confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" });
|
||||
confirmOccurrence(data, "phone", "2026-02-01", { date: "2026-02-01" });
|
||||
groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]);
|
||||
expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03-01"]);
|
||||
});
|
||||
|
||||
test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => {
|
||||
@@ -258,64 +266,38 @@ describe("expectedGroups — X3 (a cost with an outstanding slot never also show
|
||||
});
|
||||
});
|
||||
|
||||
// X1: a coming-up row confirms at today's date rather than its own slot, and
|
||||
// `occurrencesOf` throws — permanently, since the purchase persists — if that
|
||||
// pairing cannot advance the series. The band must never OFFER such a row.
|
||||
describe("expectedGroups — X1 (a coming-up row that cannot be confirmed coherently is never offered)", () => {
|
||||
test("wouldAdvancePast identifies the exact reported pairing as unsafe", () => {
|
||||
const car = cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
// 39 days between "today" and the slot — more than one 30-day cycle.
|
||||
expect(wouldAdvancePast(car, "2026-10-13", "2026-09-04")).toBe(false);
|
||||
// A slot close enough to today (a few days, the "paid early" case) is fine.
|
||||
expect(wouldAdvancePast(car, "2026-09-13", "2026-09-04")).toBe(true);
|
||||
});
|
||||
|
||||
test("stays suppressed even once X3 no longer applies — settling this month doesn't make a too-early payment safe", () => {
|
||||
// Same Car insurance shape, but September's slot is settled ON TIME
|
||||
// first, so X3 no longer has anything to suppress. The October row is
|
||||
// still 39 days from `today` — more than one 30-day cycle — so X1 alone
|
||||
// must keep it off the list.
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-09-13", { date: "2026-09-13" });
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming an unsafe pairing directly still throws — the domain guard is untouched", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-10-13", { date: "2026-09-04" });
|
||||
expect(() => occurrencesByMonth(data, "2026-10")).toThrow(
|
||||
/did not advance past 2026-10-13 \(produced 2026-10-04\)/,
|
||||
);
|
||||
});
|
||||
|
||||
// X1 used to be: a coming-up row confirms at today's date rather than its own
|
||||
// slot, and the OLD `occurrencesOf` walk threw — permanently, since the
|
||||
// purchase persists — if that pairing could not advance the series, so the
|
||||
// band asked `wouldAdvancePast` before ever offering such a row.
|
||||
//
|
||||
// CONTROLLER RULING (Task 3): `wouldAdvancePast` and the slot-rebasing model
|
||||
// it protected against are deleted outright — an anchored occurrence has no
|
||||
// slot to be "confirmed incoherently" against any more; a charge's own date
|
||||
// is always real, and the next due date is simply one cycle after the
|
||||
// latest of whatever charges exist, which can never fail to advance for a
|
||||
// valid (n >= 1) recurrence. Two tests below tested exactly the deleted
|
||||
// mechanism and are gone with it:
|
||||
// - "wouldAdvancePast identifies the exact reported pairing as unsafe" —
|
||||
// tested the deleted function directly.
|
||||
// - "stays suppressed even once X3 no longer applies — settling this month
|
||||
// doesn't make a too-early payment safe" — tested the band's X1
|
||||
// suppression, which `ExpectedBand.tsx` no longer performs (Task 3 only
|
||||
// removed the now-uncompilable `wouldAdvancePast` call so this task's own
|
||||
// suite would run; Task 6/7 own reintroducing whatever anchored-model
|
||||
// equivalent the band needs, if any — there is no such thing to suppress
|
||||
// against under the new model in the first place).
|
||||
// - "confirming an unsafe pairing directly still throws — the domain guard
|
||||
// is untouched" — described a throw that the new model cannot produce:
|
||||
// confirming "car" for slot 2026-10-13 dated 2026-09-04 now simply makes
|
||||
// 2026-09-04 the latest charge, and the series steps forward 30 days from
|
||||
// THAT real date (to 2026-10-04) instead of failing to advance past a
|
||||
// slot that no longer exists.
|
||||
// The "regression" test survives (day-granular dates updated) because its own
|
||||
// assertion — confirming whatever the band offers, then folding, never
|
||||
// throws — is still true, and now trivially so: nothing in the anchored walk
|
||||
// can throw from a confirmation's date any more.
|
||||
describe("expectedGroups — coming-up confirmations never break the fold (formerly X1)", () => {
|
||||
test("regression: whatever expectedGroups offers as 'coming up', confirming it all today and folding never throws (demo Car insurance shape)", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
@@ -330,12 +312,12 @@ describe("expectedGroups — X1 (a coming-up row that cannot be confirmed cohere
|
||||
cost({
|
||||
id: "phone",
|
||||
name: "Phone",
|
||||
startDate: "2026-01",
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car
|
||||
data.purchases = [confirmed("phone", "2026-02-01", "p0")]; // settled through Feb, unlike Car
|
||||
const today = "2026-09-04";
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", today);
|
||||
|
||||
@@ -5,11 +5,7 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { today } from "../../store/index.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import {
|
||||
occurrencesByMonth,
|
||||
wouldAdvancePast,
|
||||
type Occurrence,
|
||||
} from "../../domain/occurrences.ts";
|
||||
import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts";
|
||||
import { addMonths, compareMonths } from "../../domain/months.ts";
|
||||
import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts";
|
||||
import { Section } from "./Section.tsx";
|
||||
@@ -148,7 +144,7 @@ export function expectedGroups(
|
||||
seen.add(occurrence.recurringId);
|
||||
if (unresolved.has(occurrence.recurringId)) continue; // X3
|
||||
const cost = dataset.recurring.find((c) => c.id === occurrence.recurringId);
|
||||
if (!cost || !wouldAdvancePast(cost, occurrence.date, today)) continue; // X1
|
||||
if (!cost) continue; // X1
|
||||
comingUp.push(occurrence);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user