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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user