fix(recurring): never offer a coming-up row that can't be confirmed coherently

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 `<h3>` (matching MonthRoute's own
group-heading convention) instead of a `<p>`, 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 <id> 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.
This commit is contained in:
Lucas Winther committed 2026-09-04 09:15:11 +02:00
1 parent 17f9a44b65
commit 1281362388
2 files changed
+259 -23

No files matched your search

+195 -11
View File
@@ -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();
});
});
+64 -12
View File
@@ -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<string>();
// 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<string>(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<Record<string, string>>({});
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 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, null))}
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
</ul>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<p className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</p>
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) => row(occurrence, today))}
{comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))}
</ul>
</div>
)}