Files
budget2.0/src/ui/components/ExpectedBand.test.ts
T
Lucas Winther 1281362388 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.
2026-09-04 09:15:11 +02:00

353 lines
15 KiB
TypeScript

import { describe, expect, test } from "bun:test";
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";
const DKK_USD = [
{ code: "DKK", symbol: "kr" },
{ code: "USD", symbol: "$" },
];
// I5: an unrecognised currency must not silently confirm at the cost's own
// amount. `resolveExpectedAmount` is the logic ExpectedBand's Confirm button
// uses to decide whether to write a purchase at all.
describe("resolveExpectedAmount", () => {
test("untouched (typed undefined) is not invalid, and parses to nothing — the fast path", () => {
const result = resolveExpectedAmount(undefined, DKK_USD, "DKK");
expect(result.invalid).toBe(false);
expect(result.parsed).toBeNull();
});
test("edited text that parses is not invalid", () => {
const result = resolveExpectedAmount("450", DKK_USD, "DKK");
expect(result.invalid).toBe(false);
expect(result.parsed).toEqual({ amount: 450, currency: "DKK" });
});
test("edited text naming a currency the dataset doesn't have is invalid, not a silent fallback", () => {
// This is the exact bug: "30 GBP" in a DKK/USD dataset used to fall back
// to the cost's own amount via `parsed ?? undefined`.
const result = resolveExpectedAmount("30 GBP", DKK_USD, "DKK");
expect(result.invalid).toBe(true);
expect(result.parsed).toBeNull();
});
test("edited text that is empty or otherwise unparseable is invalid", () => {
expect(resolveExpectedAmount("", DKK_USD, "DKK").invalid).toBe(true);
expect(resolveExpectedAmount("thirty", DKK_USD, "DKK").invalid).toBe(true);
});
});
// 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";
return data;
}
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
return {
id: "r1",
name: "Rent",
order: 0,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar",
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
...overrides,
};
}
function confirmed(recurringId: string, occurrenceDate: string, id: string): Purchase {
return {
id,
date: occurrenceDate,
description: "x",
total: { amount: 8000, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
schedule: null,
source: { recurringId, occurrenceDate },
};
}
// 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", TODAY);
expect(groups.pending).toEqual([]);
expect(groups.overdue).toBe(0);
expect(groups.comingUp).toEqual([]);
});
test("a lastCharge bill's next occurrence next month is offered as 'coming up', not hidden until you navigate", () => {
// The flagship scenario: displayed month is February, the pending slot
// 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.
const data = baseDataset();
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", 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");
});
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. 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({
id: "gym",
startDate: "2026-02-02",
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", 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");
expect(forThisCost.length).toBe(1);
expect(forThisCost[0]!.date).toBe("2026-03-02"); // the soonest one past February
});
test("the horizon is bounded: a cost whose next occurrence lands beyond it does not appear", () => {
const data = baseDataset();
// Interval bigger than the default horizon: confirmed this month, the
// next occurrence is 15 months out — past the 12-month default.
data.recurring = [
cost({
id: "insurance",
startDate: "2026-02",
recurrence: { kind: "everyNMonths", n: 15 },
}),
];
data.purchases = [confirmed("insurance", "2026-02", "p1")];
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", TODAY, 16).comingUp;
expect(wider.length).toBe(1);
expect(wider[0]!.date).toBe("2027-05");
});
test("a confirmed future occurrence never appears in comingUp", () => {
const data = baseDataset();
// Ends right after March, so April onward is never generated at all —
// isolating the point of the test (a confirmed slot is excluded) from an
// unrelated, genuinely-unconfirmed one the walk would otherwise reach.
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", TODAY);
expect(groups.pending).toEqual([]);
expect(groups.comingUp).toEqual([]);
});
test("overdue counts only earlier, foldable months and is unaffected by extending the walk for comingUp", () => {
const data = baseDataset();
data.settings.foldStartMonth = "2026-01";
data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed
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();
});
});