feat(domain): anchored costs have no slots

A lastCharge cost's next due date is one cycle after the latest actual charge.
So it can be charged any number of times per cycle, each charge restarts the
clock, and the reported bug is gone: two payments in September leave the next
due in October, not November.

calendar is untouched and keeps its grid. Paying September AND October's rent
really does mean nothing is due until November — the bug was only ever a bug
for the anchored case.

occurrencesOf's second parameter changes from a confirmations Map to the raw
purchase list: the anchored walk needs chargesFor's view (every Purchase.date
for the cost, latest wins), the grid walk needs confirmationsFor's view
(purchases keyed by the slot they settle), and only occurrencesOf itself knows
which to build. occurrencesByMonth now passes dataset.purchases straight
through instead of pre-building a map.

stepFrom's `anchoring === "lastCharge" && date !== monthOf(date)` guard (Task
2 scaffolding, for the old month-only-cursor walk) is deleted. The anchored
walk's cursor is always day-granular now (startDate or a real charge date), so
the condition was dead; if a month-only date ever reaches that branch,
addMonthsToDate throwing is correct, not a bug to paper over.

confirmationsFor's `if (!purchase.source.occurrenceDate) continue` scaffolding
guard (also Task 2) is now a thrown error instead: the function is calendar-
only, a lastCharge charge should never reach it, and continuing silently would
hide a programming error as a slot that merely looks unpaid.

DELETED: rebasedFrom, wouldAdvancePast, requireConfirmationAdvances, and the
tests that existed only to exercise them (6 in occurrences.test.ts describing
the old Map-keyed lastCharge slot-rebasing and its termination throw, 3 in
ExpectedBand.test.ts exercising the band's X1 "would this pairing advance the
series" suppression, 5 in actions.test.ts exercising
requireConfirmationAdvances's refusal and its pass-throughs — see each file's
CONTROLLER RULING comment for the line-by-line reasoning). All three existed
to stop a confirmation landing behind the slot it claims; with no slots on the
anchored path and no rebasing on the grid path, that situation cannot arise
any more. The anchored walk's own termination throw stays, as a defensive
assert against n < 1 — never softened into a break.

ExpectedBand.tsx and actions.ts also had their wouldAdvancePast-dependent code
removed (the X1 condition and requireConfirmationAdvances respectively) so
this task's suite would compile and run; Task 6/7 own reintroducing whatever
anchored-model-correct guard, if any, replaces them.

This removes more code than it adds.
This commit is contained in:
Lucas Winther
2026-09-05 01:22:26 +02:00
parent f8591fdc57
commit 3649a37cfa
6 changed files with 340 additions and 422 deletions
+50 -68
View File
@@ -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);
+2 -6
View File
@@ -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);
}
}