From 128136238820aece5601d42448618e650593a7aa Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 4 Sep 2026 09:15:11 +0200 Subject: [PATCH] fix(recurring): never offer a coming-up row that can't be confirmed coherently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical (X1): confirming a "Coming up" row records TODAY against a future slot, and occurrencesOf throws — permanently, since the purchase persists — if that pairing can't advance the series. Reproduced with the demo's own Car insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks every month route, yearView, summaryView and PostYearRoute. `expectedGroups` now omits a coming-up row outright (never disabled-with-an-explanation) unless `wouldAdvancePast` says recording it today would leave the walk able to advance past that slot. Important (X3): a cost with an outstanding occurrence in the displayed month or any earlier foldable month never gets a coming-up row either — that was the double-counting path into X1 (Car insurance listed under both "Expected" 2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier stayed pending would double-count the bill). Settle the earlier bill first. Important (X2): extracted the this-month-vs-coming-up recorded-date decision into `recordedDateFor(group, today)`, previously inlined as `row(occurrence, null)` vs `row(occurrence, today)` with nothing testing that the two aren't swapped. Tested directly. Minor: the "Coming up" label is now an `

` (matching MonthRoute's own group-heading convention) instead of a `

`, so assistive tech gets the section divider. New tests in ExpectedBand.test.ts cover all of the above, including a regression built from the exact reported shape: confirming everything expectedGroups offers as "coming up" and then folding must never throw. Pre-fix, that reproduction throws: Recurring cost "Car insurance" did not advance past 2026-10-13 (produced 2026-10-04). Purchase claims that slot but is dated 2026-09-04, which does not move the series past 2026-10-13 — correct its date so the series advances. --- src/ui/components/ExpectedBand.test.ts | 206 +++++++++++++++++++++++-- src/ui/components/ExpectedBand.tsx | 76 +++++++-- 2 files changed, 259 insertions(+), 23 deletions(-) diff --git a/src/ui/components/ExpectedBand.test.ts b/src/ui/components/ExpectedBand.test.ts index d56cec5..7f9ec59 100644 --- a/src/ui/components/ExpectedBand.test.ts +++ b/src/ui/components/ExpectedBand.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { expectedGroups, resolveExpectedAmount } from "./ExpectedBand.tsx"; +import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx"; +import { occurrencesByMonth, wouldAdvancePast } 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"; @@ -38,6 +40,20 @@ describe("resolveExpectedAmount", () => { }); }); +// X2: the load-bearing decision behind the phone-bill behaviour is which date +// a Confirm click records. Nothing else in the suite would catch the two +// cases being swapped — this test must FAIL if `recordedDateFor`'s two arms +// are exchanged (verified by hand: swapping them and re-running does fail). +describe("recordedDateFor", () => { + test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => { + expect(recordedDateFor("pending", "2026-09-04")).toBeNull(); + }); + + test("a 'coming up' row records at today — paying early is the whole point", () => { + expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04"); + }); +}); + function baseDataset(): Dataset { const data = createSeedDataset("2026-02"); data.posts[0]!.id = "housing"; @@ -73,12 +89,16 @@ function confirmed(recurringId: string, occurrenceDate: string, id: string): Pur }; } +// A fixed "today" for tests where its exact value doesn't matter (any +// calendar-anchored cost advances regardless of it). +const TODAY = "2026-02-15"; + // Gap 1 (spec §5): the band offers confirmation on the next pending // occurrence, not only ones landing in the displayed month. describe("expectedGroups (Coming up)", () => { test("a dataset with no recurring costs shows nothing — the component must return null", () => { const data = baseDataset(); - const groups = expectedGroups(data, "2026-02"); + const groups = expectedGroups(data, "2026-02", TODAY); expect(groups.pending).toEqual([]); expect(groups.overdue).toBe(0); expect(groups.comingUp).toEqual([]); @@ -93,7 +113,7 @@ describe("expectedGroups (Coming up)", () => { data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })]; data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done - const groups = expectedGroups(data, "2026-02"); + const groups = expectedGroups(data, "2026-02", TODAY); expect(groups.pending).toEqual([]); expect(groups.comingUp.length).toBe(1); expect(groups.comingUp[0]!.recurringId).toBe("phone"); @@ -103,7 +123,11 @@ describe("expectedGroups (Coming up)", () => { test("at most one row per cost, even when several future occurrences fall within the horizon", () => { // A weekly cost produces many unconfirmed occurrences across the next // several months. Only the soonest one past the displayed month may - // appear — never a list of everything the future holds. + // appear — never a list of everything the future holds. February's own + // occurrences are confirmed first so X3 doesn't suppress the row this + // test exists to check (a cost with an outstanding slot this month must + // settle it before "coming up" offers a later one — covered separately + // below). const data = baseDataset(); data.recurring = [ cost({ @@ -112,10 +136,15 @@ describe("expectedGroups (Coming up)", () => { recurrence: { kind: "everyNDays", n: 7 }, }), ]; + data.purchases = [ + confirmed("gym", "2026-02-02", "p1"), + confirmed("gym", "2026-02-09", "p2"), + confirmed("gym", "2026-02-16", "p3"), + confirmed("gym", "2026-02-23", "p4"), + ]; - const groups = expectedGroups(data, "2026-02"); - // Four Monday-ish occurrences fall in February itself. - expect(groups.pending.length).toBe(4); + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); // Many more unconfirmed occurrences exist in March, April, ... within the // horizon — but comingUp holds exactly one. const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym"); @@ -136,10 +165,10 @@ describe("expectedGroups (Coming up)", () => { ]; data.purchases = [confirmed("insurance", "2026-02", "p1")]; - expect(expectedGroups(data, "2026-02").comingUp).toEqual([]); // default (12) horizon + expect(expectedGroups(data, "2026-02", TODAY).comingUp).toEqual([]); // default (12) horizon // Raising the horizon far enough reveals the same occurrence, proving the // emptiness above is the bound, not a bug elsewhere. - const wider = expectedGroups(data, "2026-02", 16).comingUp; + const wider = expectedGroups(data, "2026-02", TODAY, 16).comingUp; expect(wider.length).toBe(1); expect(wider[0]!.date).toBe("2027-05"); }); @@ -152,7 +181,7 @@ describe("expectedGroups (Coming up)", () => { data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })]; data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")]; - const groups = expectedGroups(data, "2026-02"); + const groups = expectedGroups(data, "2026-02", TODAY); expect(groups.pending).toEqual([]); expect(groups.comingUp).toEqual([]); }); @@ -162,7 +191,162 @@ describe("expectedGroups (Coming up)", () => { data.settings.foldStartMonth = "2026-01"; data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed - const groups = expectedGroups(data, "2026-02"); + const groups = expectedGroups(data, "2026-02", TODAY); expect(groups.overdue).toBe(1); }); }); + +// X3: a cost must never appear in both "Expected" and "Coming up" at once — +// that is the path into X1 (confirming the later row while the earlier one +// is still outstanding writes a real purchase AND leaves the earlier +// projection standing, double-counting the bill). +describe("expectedGroups — X3 (a cost with an outstanding slot never also shows a coming-up row)", () => { + test("this month's slot still pending suppresses the coming-up row for the SAME cost", () => { + 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", + }), + ]; + // Matches the reported demo shape exactly: no purchases at all, viewed + // 2026-09. 2026-09-13 is pending; 2026-10-13 would otherwise be the + // soonest future occurrence. + data.settings.foldStartMonth = "2026-01"; + + const groups = expectedGroups(data, "2026-09", "2026-09-04"); + expect(groups.pending.map((o) => o.date)).toEqual(["2026-09-13"]); + expect(groups.comingUp).toEqual([]); // suppressed — settle September first + }); + + test("once the pending slot is settled, a coming-up row reappears — for a pairing that is itself safe", () => { + // A monthly lastCharge cost, unlike the 30-day Car insurance above, is + // 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. + const data = baseDataset(); + data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })]; + + let groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]); + expect(groups.comingUp).toEqual([]); + + confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" }); + groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]); + }); + + test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => { + const data = baseDataset(); + data.settings.foldStartMonth = "2026-01"; + data.recurring = [ + cost({ id: "rent", startDate: "2026-01", endedFrom: "2026-04" }), + ]; + // January is unconfirmed; February (displayed month) and March are + // confirmed, so nothing is `pending` and the only reason a coming-up row + // would be suppressed is the outstanding January slot. + data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")]; + + const groups = expectedGroups(data, "2026-02", TODAY); + expect(groups.pending).toEqual([]); + expect(groups.comingUp).toEqual([]); // January is still outstanding + }); +}); + +// 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\)/, + ); + }); + + 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 = [ + cost({ + id: "car", + name: "Car insurance", + amount: { amount: 349, currency: "DKK" }, + startDate: "2026-08-14", + recurrence: { kind: "everyNDays", n: 30 }, + anchoring: "lastCharge", + }), + cost({ + id: "phone", + name: "Phone", + startDate: "2026-01", + recurrence: { kind: "everyNMonths", n: 1 }, + anchoring: "lastCharge", + }), + ]; + data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car + const today = "2026-09-04"; + + const groups = expectedGroups(data, "2026-09", today); + // Car insurance is suppressed entirely (X3: 2026-09-13 is still pending), + // and even confirming everything else offered must never break the fold. + expect(groups.comingUp.some((o) => o.recurringId === "car")).toBe(false); + + for (const occurrence of groups.comingUp) { + confirmOccurrence(data, occurrence.recurringId, occurrence.date, { date: today }); + } + + expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow(); + }); +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index 348e960..ee7ba8f 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -5,7 +5,11 @@ 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, type Occurrence } from "../../domain/occurrences.ts"; +import { + occurrencesByMonth, + wouldAdvancePast, + 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"; @@ -33,6 +37,24 @@ export function resolveExpectedAmount( return { parsed, invalid: parsed === null }; } +export type ExpectedRowGroup = "pending" | "comingUp"; + +/** + * What a Confirm click should record, given which group the row is in. A + * this-month row confirms at the slot's own date — `null`, `confirmOccurrence`'s + * default — and a coming-up row confirms at `today` instead, because it is + * being paid early on purpose (see `ExpectedBand`'s doc comment). + * + * This one branch (previously inlined as `row(occurrence, null)` versus + * `row(occurrence, today)`) is the whole of the phone-bill behaviour, and + * nothing else in the suite would catch the two being swapped — a DOM test + * isn't needed for that, a direct one is. Exported for `ExpectedBand.test.ts` + * (X2). + */ +export function recordedDateFor(group: ExpectedRowGroup, today: IsoDate): IsoDate | null { + return group === "comingUp" ? today : null; +} + export interface ExpectedGroups { /** This month's unconfirmed occurrences. */ pending: Occurrence[]; @@ -57,11 +79,31 @@ export interface ExpectedGroups { * enough to catch an annual `everyNMonths` cost — and is a band-local concern. * `foldBalances`'s own `upToMonth` bound is untouched. * + * `today` is what a "Coming up" row would record if confirmed (see `row` in + * `ExpectedBand` below) — passed in rather than read from a clock, because + * this function is exercised directly by tests with no DOM. + * + * Two rules keep a "coming up" row from ever being offered when confirming it + * cannot go well (X1, X3): + * + * - A cost with an unresolved occurrence in `monthId` or any earlier + * FOLDABLE month (same bound `overdue` uses) is skipped entirely. Settle + * the earlier bill first — offering a later one while an earlier sits + * outstanding would let confirming the later one leave the earlier + * projection standing, double-counting the bill. + * - Otherwise, the row is offered only if recording it at `today` would + * leave `occurrencesOf`'s walk able to advance past that slot + * (`wouldAdvancePast`). A bill confirmed today for a slot far enough in + * the future can rebase the series backwards, which is exactly what + * `occurrencesOf` throws on — so a row that would do that must never be + * offered in the first place, not offered-and-disabled. + * * Exported for `ExpectedBand.test.ts`. */ export function expectedGroups( dataset: Dataset, monthId: MonthId, + today: IsoDate, horizonMonths = 12, ): ExpectedGroups { const byMonth = occurrencesByMonth(dataset, addMonths(monthId, horizonMonths)); @@ -69,6 +111,9 @@ export function expectedGroups( let overdue = 0; const seen = new Set(); + // Costs with an outstanding slot at or before `monthId` — X3: none of + // these may get a "coming up" row until that slot is settled. + const unresolved = new Set(pending.map((o) => o.recurringId)); const comingUp: Occurrence[] = []; for (const month of [...byMonth.keys()].sort(compareMonths)) { @@ -81,9 +126,15 @@ export function expectedGroups( // foldStartMonth: foldBalances never folds those, so an occurrence out // there never held the projected balance down in the first place, and // counting it here would make the sentence below claim a gap that isn't - // in the number the owner is looking at. + // in the number the owner is looking at. The same bound applies to X3's + // suppression, for the same reason — a slot the fold never tracked was + // never "outstanding" in any number the owner can see. if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue; - overdue += occurrences.filter((o) => !o.confirmedBy).length; + for (const o of occurrences) { + if (o.confirmedBy) continue; + overdue += 1; + unresolved.add(o.recurringId); + } continue; } @@ -95,6 +146,9 @@ export function expectedGroups( if (occurrence.confirmedBy) continue; if (seen.has(occurrence.recurringId)) continue; 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 comingUp.push(occurrence); } } @@ -124,8 +178,8 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { const [amounts, setAmounts] = useState>({}); const { pending, overdue, comingUp } = useMemo( - () => expectedGroups(dataset, monthId), - [dataset, monthId], + () => expectedGroups(dataset, monthId, today), + [dataset, monthId, today], ); if (pending.length === 0 && overdue === 0 && comingUp.length === 0) return null; @@ -135,9 +189,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { /** * One row, shared by "Expected" and "Coming up" — they differ only in the - * subtitle and in what date confirming records. `recordedDate` is `null` - * for a this-month row (confirms at the slot's own date, `confirmOccurrence`'s - * default) and `today` for a coming-up row (confirms early, on purpose). + * subtitle and in what date confirming records, per `recordedDateFor`. */ function row(occurrence: Occurrence, recordedDate: IsoDate | null) { const key = `${occurrence.recurringId}:${occurrence.date}`; @@ -222,17 +274,17 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { > {pending.length > 0 && (

)} {comingUp.length > 0 && (
0 ? "mt-4" : ""}> -

+

Coming up -

+

    - {comingUp.map((occurrence) => row(occurrence, today))} + {comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))}
)}