diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 26f02f3..39fe380 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -18,6 +18,7 @@ function cost(overrides: Partial = {}): 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(); +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 { + 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]); + }); +}); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index aebc581..bd51699 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -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 { 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); + 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, 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(); 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); diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index aef132a..4536547 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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. diff --git a/src/store/actions.ts b/src/store/actions.ts index 99ad6d2..c117c63 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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>, ): 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> = { ...changes }; if (changes.total) { resolved.total = roundMoneyValue(draft, changes.total); diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts index 7f9ec59..8b4f026 100644 --- a/src/ui/components/ExpectedBand.test.ts +++ b/src/ui/components/ExpectedBand.test.ts @@ -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); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index ee7ba8f..2feeafd 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -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); } }