fix(domain): the anchored walk is day-precise under every recurrence kind

The precheck in `anchoredOccurrences` covered `everyNMonths` only, so a
month-only starting point on an `everyNDays` or `everyNWeeks` anchored cost
fell straight through to `addDays` and threw a bare "Not a day-granular
IsoDate", naming no cost, several frames below `foldBalances` — the exact
failure the precheck exists to prevent, for two thirds of the costs that can
have it. The walk is day-precise under all three kinds, so the check is too.

The rule about WHICH dates need a day was written out separately in four
places (both write boundaries and both controls that offer the pairings). It
now lives once, in `granularity.ts`, alongside the second, different rule the
next commits need: an anchored cost has no slots, so its latest CHARGE's own
`Purchase.date` is the anchor the series steps from, whatever the kind.

Three comments corrected to describe the code that exists:

- `stepFrom` and `anchoredOccurrences` both claimed neither write boundary
  rejects a month-only `startDate`. Both do, and have since this model landed.
  Pointing the reader at the closed hole is why the open one — the CHARGE's
  own date — survived four reviews.
- `Anchoring`'s doc still said confirming the occurrence early is the whole
  mechanism. An anchored cost has no occurrence to confirm; recording a charge
  is the mechanism.
- `Occurrence.date` described slot identity as universal (it is calendar-only),
  and `confirmedBy` did not say it is unconditionally null on the anchored path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-06 18:31:40 +02:00
co-authored by Claude Opus 5
parent 49811f6795
commit d542a6bdab
6 changed files with 216 additions and 32 deletions
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import {
chargeDateNeedsDay,
isDayGranularDate,
startDateNeedsDay,
} from "./granularity.ts";
describe("isDayGranularDate", () => {
test("a day-granular date carries a day; a month-only one does not", () => {
expect(isDayGranularDate("2026-09-17")).toBe(true);
expect(isDayGranularDate("2026-09")).toBe(false);
});
test("an empty or malformed value is not day-granular", () => {
expect(isDayGranularDate("")).toBe(false);
expect(isDayGranularDate("not-a-date")).toBe(false);
expect(isDayGranularDate("2026-9-1")).toBe(false);
});
test("shape only — a calendar-impossible day still reads as day-granular", () => {
// Deliberate: whether a date is REAL is a separate question, answered by
// `toDayOrdinal` at the boundaries that need it.
expect(isDayGranularDate("2026-09-31")).toBe(true);
expect(isDayGranularDate("2026-02-30")).toBe(true);
});
});
/**
* The one statement of a rule that used to be written out in four places —
* both write boundaries and both controls that offer these pairings. Four
* copies is how a fifth place ends up without one, which is exactly what
* happened to the CHARGE side of the same rule below.
*/
describe("startDateNeedsDay", () => {
test("everyNMonths under calendar is the ONLY pairing that tolerates a month", () => {
expect(startDateNeedsDay("everyNMonths", "calendar")).toBe(false);
});
test("everyNMonths under lastCharge needs a day — it steps from a real charge date", () => {
expect(startDateNeedsDay("everyNMonths", "lastCharge")).toBe(true);
});
test("everyNDays and everyNWeeks need a day under either anchoring — they reach addDays", () => {
expect(startDateNeedsDay("everyNDays", "calendar")).toBe(true);
expect(startDateNeedsDay("everyNDays", "lastCharge")).toBe(true);
expect(startDateNeedsDay("everyNWeeks", "calendar")).toBe(true);
expect(startDateNeedsDay("everyNWeeks", "lastCharge")).toBe(true);
});
});
/**
* A DIFFERENT rule from the one above, because the two dates are read
* differently: a calendar charge names the slot it settles and its own date is
* never stepped from, while an anchored cost has no slots at all and its
* latest charge's date IS the anchor.
*/
describe("chargeDateNeedsDay", () => {
test("an anchored cost's charge needs a day; a calendar cost's does not", () => {
expect(chargeDateNeedsDay("lastCharge")).toBe(true);
expect(chargeDateNeedsDay("calendar")).toBe(false);
});
test("it does not depend on the recurrence kind, unlike the startDate rule", () => {
// Every kind is day-precise under `lastCharge`: everyNMonths reaches
// addMonthsToDate, the other two reach addDays. So there is nothing for a
// kind to change here — and `startDateNeedsDay` agrees on that side.
for (const kind of ["everyNMonths", "everyNDays", "everyNWeeks"] as const) {
expect(startDateNeedsDay(kind, "lastCharge")).toBe(true);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { Anchoring, IsoDate, Recurrence } from "./types.ts";
/**
* "YYYY-MM-DD" vs "YYYY-MM" — whether a date carries a day at all.
*
* Shape only, and deliberately: "2026-09-31" is day-GRANULAR and calendar-
* impossible, and the two are separate questions. A boundary that needs the
* second one routes the value through `toDayOrdinal`/`monthOf`, which already
* throw on exactly that.
*/
const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Whether `date` names a specific day, rather than a whole month. */
export function isDayGranularDate(date: IsoDate): boolean {
return DAY_GRANULAR_DATE.test(date);
}
/**
* Whether a recurring cost's `startDate` must name a specific day, given the
* schedule it describes. The ONE statement of this rule: both write
* boundaries (`store/actions.ts`, `export/json.ts`) and both controls that
* offer these pairings (`ui/recurringForm.ts`, the Settings recurring table)
* call this rather than restating it, because four copies of a rule is how a
* fifth place ends up without one.
*
* `everyNMonths` under `calendar` anchoring is the single exception: that grid
* walks its cursor through `monthOf`, so a month-only date is exactly right —
* rent occurs IN a month. Every other pairing walks day-precisely:
* `everyNDays` and `everyNWeeks` hand the date straight to `addDays`
* regardless of anchoring, and `everyNMonths` under `lastCharge` steps through
* `addMonthsToDate` because the next due date is measured from a real charge.
*/
export function startDateNeedsDay(kind: Recurrence["kind"], anchoring: Anchoring): boolean {
return kind !== "everyNMonths" || anchoring === "lastCharge";
}
/**
* Whether a CHARGE's own `Purchase.date` must name a specific day, given the
* anchoring of the cost it names.
*
* A separate rule from `startDateNeedsDay`, because the two dates are read
* differently. A `calendar` cost's charge is only ever placed in a month (its
* slot lives in `source.occurrenceDate`), so a month-only purchase date is
* fine there whatever the recurrence kind. A `lastCharge` cost has no slots at
* all: `anchoredOccurrences` takes the LATEST charge's own date as its anchor
* and steps forward from it, so that date reaches `addDays`/`addMonthsToDate`
* under every recurrence kind and must carry a day.
*/
export function chargeDateNeedsDay(anchoring: Anchoring): boolean {
return anchoring === "lastCharge";
}
+1
View File
@@ -4,6 +4,7 @@ export * from "./types.ts";
export * from "./money.ts";
export * from "./months.ts";
export * from "./days.ts";
export * from "./granularity.ts";
export * from "./fx.ts";
export * from "./allocation.ts";
export * from "./charges.ts";
+39
View File
@@ -359,6 +359,45 @@ describe("the anchored walk (lastCharge)", () => {
expect(() => occurrencesOf(untimed, [], "2026-11")).not.toThrow(/day-granular IsoDate/);
});
// The precheck above covered `everyNMonths` only, so the other two kinds
// fell straight through to `addDays` and threw a BARE "Not a day-granular
// IsoDate", naming no cost — the failure the precheck exists to prevent,
// for two thirds of the anchored costs that can have it. This walk is
// day-precise under every kind, so the check is too.
test("a month-only startDate names the cost under everyNDays as well", () => {
const daily = phone({ recurrence: { kind: "everyNDays", n: 30 }, startDate: "2026-09" });
expect(() => occurrencesOf(daily, [], "2026-11")).toThrow(/Phone plan/);
expect(() => occurrencesOf(daily, [], "2026-11")).not.toThrow(/day-granular IsoDate/);
});
test("a month-only startDate names the cost under everyNWeeks as well", () => {
const weekly = phone({
recurrence: { kind: "everyNWeeks", n: 2, weekday: 4 },
startDate: "2026-09",
});
expect(() => occurrencesOf(weekly, [], "2026-11")).toThrow(/Phone plan/);
expect(() => occurrencesOf(weekly, [], "2026-11")).not.toThrow(/day-granular IsoDate/);
});
// Not just `startDate`: the anchor is the LATEST CHARGE's own date once
// there is one, and `Purchase.date`'s documented shape allows "YYYY-MM".
// Both write boundaries refuse that now, but the walk still has to name the
// cost rather than throw a bare parse error for a dataset they never saw.
test("a month-only CHARGE date names the cost too, under every kind", () => {
for (const recurrence of [
{ kind: "everyNMonths", n: 1 },
{ kind: "everyNDays", n: 30 },
{ kind: "everyNWeeks", n: 2, weekday: 4 },
] as const) {
// startDate before the charge, so `max(latest, startDate)` picks the
// charge's own month-only date as the anchor.
const cost = phone({ recurrence, startDate: "2026-08-04" });
const charges = [charge("p1", "2026-09")];
expect(() => occurrencesOf(cost, charges, "2026-11")).toThrow(/Phone plan/);
expect(() => occurrencesOf(cost, charges, "2026-11")).not.toThrow(/day-granular IsoDate/);
}
});
test("unconfirmed across many months, a day-granular anchored cost just keeps stepping", () => {
// CONTROLLER RULING: replaces round-1's "unconfirmed, it behaves exactly
// like a calendar series" (old describe "lastCharge anchoring: the phone
+40 -28
View File
@@ -1,4 +1,5 @@
import { addDays, addMonthsToDate, weekdayOf } from "./days.ts";
import { isDayGranularDate } from "./granularity.ts";
import { addMonths, compareMonths, monthOf } from "./months.ts";
import type {
Anchoring,
@@ -22,13 +23,24 @@ import type {
export interface Occurrence {
recurringId: RecurringCostId;
/**
* The slot this walk generated. IDENTITY: a confirmation claims a slot by
* this date, which is not necessarily the date it was paid.
* When this projected charge falls.
*
* For a `calendar` cost this date is also an IDENTITY: it names a slot on a
* fixed grid, and a charge claims that slot through
* `Purchase.source.occurrenceDate`, which is not necessarily the date it was
* paid. A `lastCharge` cost has no grid and no slots, so nothing ever refers
* back to this date — it is a projection and nothing more.
*/
date: IsoDate;
/** Expected amount, in the cost's own currency. */
amount: Money;
/** The purchase that confirmed this slot, or null while it is still expected. */
/**
* The purchase that confirmed this slot, or null while it is still expected.
*
* Calendar-only in practice: the anchored walk sets this to null
* unconditionally, because a charge that happened is a `Purchase` counted as
* an actual, never an occurrence it could confirm.
*/
confirmedBy: PurchaseId | null;
}
@@ -42,9 +54,6 @@ function isBefore(a: IsoDate, b: IsoDate): boolean {
return a < b;
}
/** "YYYY-MM-DD" vs "YYYY-MM" — whether a date carries a day at all. */
const DAY_GRANULAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Moves forward to the next occurrence of `weekday`, or stays put if already on it. */
function snapForward(date: IsoDate, weekday: number): IsoDate {
const delta = (weekday - weekdayOf(date) + 7) % 7;
@@ -61,16 +70,17 @@ function snapForward(date: IsoDate, weekday: number): IsoDate {
* step is day-precise and clamps (`addMonthsToDate`). The other two kinds are
* day-granular either way and ignore the argument.
*
* `anchoredOccurrences` checks its starting cursor is day-granular before
* ever calling this for an `everyNMonths` cost, and throws an error naming
* the cost if it is not. That check exists BECAUSE nothing currently
* guarantees a day-granular `startDate` on an anchored `everyNMonths` cost —
* neither write boundary rejects a month-only one, and a charge's own date
* can be month-only too (`Purchase.date`'s documented shape). Without that
* check, a month-only date reaching this branch would still throw, but from
* `addMonthsToDate` with a bare parsing message that names no cost — a
* failure the owner cannot act on, several frames below `foldBalances`, that
* would take every money route down with it.
* Under `lastCharge` every kind here is day-precise, so `anchoredOccurrences`
* checks its starting cursor is day-granular before ever calling this, and
* throws an error naming the cost if it is not. Both write boundaries reject a
* month-only `startDate` on an anchored cost and a month-only date on a charge
* naming one (`startDateNeedsDay`/`chargeDateNeedsDay` in
* `domain/granularity.ts`, applied in `store/actions.ts` and `export/json.ts`),
* so that check is defensive rather than load-bearing — but it is what stands
* between a dataset those boundaries never saw (a hand-edited store, a bug in
* a later migration step) and a bare `addDays`/`addMonthsToDate` parsing
* message naming no cost, several frames below `foldBalances`, that would take
* every money route down with it.
*
* Adding a recurrence kind means adding a case here and a variant to
* `Recurrence`. Nothing else in the app switches on the kind.
@@ -194,19 +204,21 @@ function anchoredOccurrences(
const anchor = latest === null ? null : isBefore(latest, cost.startDate) ? cost.startDate : latest;
const startingPoint = anchor ?? cost.startDate;
// `stepFrom`'s `everyNMonths`/`lastCharge` branch hands its date straight
// to `addMonthsToDate`, which requires "YYYY-MM-DD" and would otherwise
// throw a bare parsing error naming no cost at all, several frames below
// `foldBalances` — taking every money route down with a message the owner
// cannot act on. Nothing at either write boundary rejects a month-only
// `startDate` on an anchored `everyNMonths` cost today (and a charge's own
// date can be month-only too, per `Purchase.date`'s documented shape), so
// check here, before the walk starts, and name the cost.
if (cost.recurrence.kind === "everyNMonths" && !DAY_GRANULAR_DATE.test(startingPoint)) {
// EVERY recurrence kind, not just `everyNMonths`: this walk is day-precise
// throughout. `everyNMonths` reaches `addMonthsToDate` and the other two
// reach `addDays`, and all three require "YYYY-MM-DD" — so a month-only
// starting point used to throw a bare parsing error naming no cost at all,
// several frames below `foldBalances`, taking every money route down with a
// message the owner cannot act on. Both write boundaries now reject the two
// ways such a date could be stored (a month-only `startDate` on an anchored
// cost, and a month-only date on a charge naming one), so this is the
// backstop for a dataset they never saw; check before the walk starts, and
// name the cost.
if (!isDayGranularDate(startingPoint)) {
throw new Error(
`Recurring cost "${cost.name}" is anchored (lastCharge) with a monthly recurrence, so every ` +
`date it steps from must be day-granular ("YYYY-MM-DD") — got "${startingPoint}". Give it a ` +
`specific day: on its startDate if it has no charges yet, or on its latest charge otherwise.`,
`Recurring cost "${cost.name}" is anchored (lastCharge), so every date its series steps from ` +
`must be day-granular ("YYYY-MM-DD") — got "${startingPoint}". Give it a specific day: on ` +
`its startDate if it has no charges yet, or on its latest charge otherwise.`,
);
}
+14 -4
View File
@@ -130,7 +130,10 @@ export type Recurrence =
* `lastCharge` — from the date the previous charge ACTUALLY happened. This is
* the phone bill: hitting the data cap makes a charge happen early, and the
* next 30 days run from there. The domain never learns what a data cap is;
* confirming the occurrence early is the whole mechanism.
* RECORDING A CHARGE is the whole mechanism. There is no occurrence to
* confirm on this side — an anchored cost has no slots, so every occurrence it
* projects is unconfirmed by definition and a charge is an ordinary
* `Purchase`, dated when it happened, whose own date anchors what comes next.
*/
export type Anchoring = "calendar" | "lastCharge";
@@ -158,9 +161,9 @@ export interface RecurringCost {
* `calendar` anchoring, where "YYYY-MM" is also allowed — that grid is
* month-granular, but every other combination needs a real day: `everyNDays`
* and `everyNWeeks` hand this straight to `addDays`, and `everyNMonths`
* under `lastCharge` steps day-precisely from a real charge date. Both write
* boundaries (`requireStartDateGranularity` in `store/actions.ts`, the
* mirrored check in `export/json.ts`) enforce this pairing.
* under `lastCharge` steps day-precisely from a real charge date. The rule
* itself is `startDateNeedsDay` in `domain/granularity.ts`, and both write
* boundaries (`store/actions.ts`, `export/json.ts`) enforce it.
*/
startDate: IsoDate;
/** Occurrences on or after this date are not projected. This is cancellation. */
@@ -174,6 +177,13 @@ export interface RecurringCost {
export interface Purchase {
id: PurchaseId;
/**
* When it was bought. Month-only is normal — the app is about monthly
* spending — EXCEPT on a charge for a `lastCharge` recurring cost, where
* this date is what the next due date is measured from and so must name a
* day. That is `chargeDateNeedsDay` in `domain/granularity.ts`, enforced at
* both write boundaries alongside the `source` pairing below.
*/
date: IsoDate;
description: string;
/** Optional longer context, alongside the short `description` label. */