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();
});
});