C1 (Critical): a month-only startDate on an anchored everyNMonths cost with
no charges yet reached addMonthsToDate directly and threw a bare parsing
error naming no cost, several frames below foldBalances. anchoredOccurrences
now checks its starting cursor's granularity before the walk begins and
throws a cost-named, actionable error instead. No new validation at either
write boundary — that stays out of scope here. stepFrom's docstring no longer
asserts a guarantee ("required to carry a day-granular startDate") that
nothing in the code enforces; it now says what actually protects it.
I1 (Important): the anchored walk's own termination throw had zero test
coverage — both surviving "termination" tests use the cost() fixture, whose
default anchoring is calendar, so they only ever exercised gridOccurrences.
Proven by mutation (replacing the anchored throw with break left every test
passing). Added a test that actually walks anchoredOccurrences, and corrected
the inline comment that falsely claimed coverage existed.
I2 (Important): confirmationsFor's throw is reachable from data the JSON
importer currently accepts (a calendar-cost purchase missing occurrenceDate),
not just a hypothetical programming error — its docstring said "programming
error upstream," which isn't true yet. Docstring corrected, and the throw is
now pinned by a test instead of only asserted in prose.
I3 (Important): nothing asserted that editing a charge's date backwards
re-anchors the series instead of throwing — exactly the behaviour deleting
requireConfirmationAdvances made structurally safe, and exactly the gap left
by deleting round-1's "a date more than one cycle before the slot it claims
is refused" test. Added the inverse: same fixture, but asserting the edit now
succeeds, the fold still folds cleanly, and the series re-anchors to the
edited date.
Minor: round-1 deleted ExpectedBand.test.ts's "stays suppressed once X3 no
longer applies" (X1-only), leaving its underlying scenario (day-granular
anchored cost, slot beyond the displayed month, X3 already satisfied)
completely untested even though still reachable. Added a test pinning what
expectedGroups actually does with it now that X1 is gone: the row is offered,
not suppressed — so a future reintroduction of suppression here is a visible,
deliberate change instead of a silent one.
Left untouched, as instructed: ExpectedBand.tsx's stale X1 docstring and the
Settings anchoring dropdown (both belong to later tasks).
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { addDays, addMonthsToDate, weekdayOf } from "./days.ts";
|
|
import { addMonths, compareMonths, monthOf } from "./months.ts";
|
|
import type {
|
|
Anchoring,
|
|
Dataset,
|
|
IsoDate,
|
|
Money,
|
|
MonthId,
|
|
Purchase,
|
|
PurchaseId,
|
|
Recurrence,
|
|
RecurringCost,
|
|
RecurringCostId,
|
|
} from "./types.ts";
|
|
|
|
/**
|
|
* One projected charge of one recurring cost.
|
|
*
|
|
* Occurrences are COMPUTED on every fold and never stored. The dataset holds
|
|
* the rule and the confirmations; everything here is derived from those two.
|
|
*/
|
|
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.
|
|
*/
|
|
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. */
|
|
confirmedBy: PurchaseId | null;
|
|
}
|
|
|
|
/**
|
|
* ISO dates compare correctly as strings, and mixed granularity falls out
|
|
* right: "2026-09" < "2026-09-17" < "2026-10", so a month-only date reads as
|
|
* the start of that month. That is exactly what `endedFrom` needs when a
|
|
* monthly cost is cancelled.
|
|
*/
|
|
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;
|
|
return delta === 0 ? date : addDays(date, delta);
|
|
}
|
|
|
|
/**
|
|
* Where the next occurrence falls, given the one before it.
|
|
*
|
|
* Takes the anchoring because `everyNMonths` means two different things:
|
|
* under `calendar` rent occurs IN a month and the grid is month-granular, so
|
|
* the step runs on `MonthId` arithmetic (`addMonths`/`monthOf`); under
|
|
* `lastCharge` a plan refreshed on the 20th is next due on the 20th, so the
|
|
* 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.
|
|
*
|
|
* Adding a recurrence kind means adding a case here and a variant to
|
|
* `Recurrence`. Nothing else in the app switches on the kind.
|
|
*/
|
|
export function stepFrom(
|
|
date: IsoDate,
|
|
recurrence: Recurrence,
|
|
anchoring: Anchoring,
|
|
): IsoDate {
|
|
switch (recurrence.kind) {
|
|
case "everyNMonths":
|
|
return anchoring === "lastCharge"
|
|
? addMonthsToDate(date, recurrence.n)
|
|
: addMonths(monthOf(date), recurrence.n);
|
|
case "everyNDays":
|
|
return addDays(date, recurrence.n);
|
|
case "everyNWeeks":
|
|
return snapForward(addDays(date, 7 * recurrence.n), recurrence.weekday);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The purchases confirming one `calendar` cost, keyed by the slot each one
|
|
* claims.
|
|
*
|
|
* Keyed by `source.occurrenceDate` rather than by `Purchase.date`, because the
|
|
* slot is what a confirmation claims. The two differ whenever a bill is paid
|
|
* off schedule.
|
|
*
|
|
* Calendar-only. A `lastCharge` cost has no slots (`Purchase.source`'s doc in
|
|
* types.ts) — its charges are walked by `chargesFor`/`anchoredOccurrences`
|
|
* off `Purchase.date` directly, and never reach this function BY DESIGN.
|
|
*
|
|
* A purchase that DOES reach here (its `recurringId` names a `calendar` cost)
|
|
* but carries no `occurrenceDate` is not only a hypothetical programming
|
|
* error: the JSON importer currently accepts a purchase missing one, and
|
|
* import replaces the whole dataset, so a file built that way loads clean and
|
|
* throws on the very next fold — the app goes down immediately, from data the
|
|
* import boundary let in. Throwing here, naming the purchase, is still better
|
|
* than the old scaffolding `continue`, which would have silently dropped the
|
|
* confirmation and left the slot looking unpaid; closing the import boundary
|
|
* itself is a separate piece of work.
|
|
*/
|
|
export function confirmationsFor(
|
|
purchases: readonly Purchase[],
|
|
recurringId: RecurringCostId,
|
|
): Map<IsoDate, Purchase> {
|
|
const found = new Map<IsoDate, Purchase>();
|
|
for (const purchase of purchases) {
|
|
if (purchase.source?.recurringId !== recurringId) continue;
|
|
if (!purchase.source.occurrenceDate) {
|
|
throw new Error(
|
|
`Purchase ${purchase.id} confirms recurring cost ${recurringId} but carries no ` +
|
|
`occurrenceDate. confirmationsFor is for the calendar walk only, where every charge ` +
|
|
`names the slot it settles — a lastCharge charge must never reach here.`,
|
|
);
|
|
}
|
|
found.set(purchase.source.occurrenceDate, purchase);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Every purchase recorded as a charge for one cost, oldest first.
|
|
*
|
|
* The anchored walk needs the LATEST of these and nothing else: a charge is
|
|
* not a confirmation of anything, it is the event that restarts the cycle.
|
|
*/
|
|
export function chargesFor(
|
|
purchases: readonly Purchase[],
|
|
recurringId: RecurringCostId,
|
|
): Purchase[] {
|
|
return purchases
|
|
.filter((p) => p.source?.recurringId === recurringId)
|
|
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
}
|
|
|
|
/**
|
|
* Every occurrence of one cost from its start through `upToMonth`, in order.
|
|
*
|
|
* Two walks, one per anchoring, sharing only `stepFrom`.
|
|
*
|
|
* `calendar` is a fixed grid: slots from `startDate`, each settled or not by
|
|
* the purchase naming it. Nothing a payment does moves the grid, so paying
|
|
* September AND October's rent correctly leaves nothing due until November.
|
|
*
|
|
* `lastCharge` has no grid. Its next due date is one cycle after the latest
|
|
* actual charge, so a bill can be charged any number of times per cycle and
|
|
* each charge restarts the clock — which is what a phone plan that refreshes
|
|
* when its data runs out actually does. Every occurrence it emits is
|
|
* unconfirmed by definition: a charge that happened is a `Purchase`, not an
|
|
* occurrence.
|
|
*
|
|
* `upToMonth` comes from the caller. This module reads no clock, which is what
|
|
* keeps `src/domain/` pure.
|
|
*/
|
|
export function occurrencesOf(
|
|
cost: RecurringCost,
|
|
purchases: readonly Purchase[],
|
|
upToMonth: MonthId,
|
|
): Occurrence[] {
|
|
return cost.anchoring === "lastCharge"
|
|
? anchoredOccurrences(cost, purchases, upToMonth)
|
|
: gridOccurrences(cost, confirmationsFor(purchases, cost.id), upToMonth);
|
|
}
|
|
|
|
/**
|
|
* The `lastCharge` walk. No slots, no rebasing rule to apply — just "one cycle
|
|
* after the latest charge, or `startDate` if there has never been one".
|
|
*/
|
|
function anchoredOccurrences(
|
|
cost: RecurringCost,
|
|
purchases: readonly Purchase[],
|
|
upToMonth: MonthId,
|
|
): Occurrence[] {
|
|
const charges = chargesFor(purchases, cost.id);
|
|
const latest = charges.length > 0 ? charges[charges.length - 1]!.date : null;
|
|
|
|
// `max(latest, startDate)`: a charge back-dated before the cost existed must
|
|
// not pull the series behind its own start.
|
|
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)) {
|
|
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.`,
|
|
);
|
|
}
|
|
|
|
let cursor = anchor === null ? cost.startDate : stepFrom(anchor, cost.recurrence, cost.anchoring);
|
|
|
|
const found: Occurrence[] = [];
|
|
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
|
|
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
|
|
|
|
found.push({
|
|
recurringId: cost.id,
|
|
date: cursor,
|
|
amount: cost.amount,
|
|
// Always null: an anchored occurrence is a projection, and a charge that
|
|
// happened is a Purchase counted in `charges`, never an occurrence here.
|
|
confirmedBy: null,
|
|
});
|
|
|
|
const next = stepFrom(cursor, cost.recurrence, cost.anchoring);
|
|
// Defensive. Stepping forward from a real date with `n >= 1` always
|
|
// advances, so this should be unreachable — but a hang is the one failure
|
|
// worse than a visible error, and both write boundaries enforce n >= 1.
|
|
if (!isBefore(cursor, next)) {
|
|
throw new Error(
|
|
`Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` +
|
|
`A recurrence must move strictly forward; check that n is at least 1.`,
|
|
);
|
|
}
|
|
cursor = next;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* The `calendar` walk: a fixed grid of slots from `startDate`, stepping
|
|
* forward by `stepFrom` regardless of when (or whether) each slot was paid.
|
|
*
|
|
* Unlike the anchored walk, a confirmation never moves this cursor — paying
|
|
* September AND October's rent correctly leaves nothing due until November,
|
|
* which is exactly why `calendar` was never the buggy case.
|
|
*/
|
|
function gridOccurrences(
|
|
cost: RecurringCost,
|
|
confirmations: ReadonlyMap<IsoDate, Purchase>,
|
|
upToMonth: MonthId,
|
|
): Occurrence[] {
|
|
const found: Occurrence[] = [];
|
|
|
|
// A monthly cost is month-granular throughout, even if its start date was
|
|
// typed with a day. Rent occurs IN a month.
|
|
let cursor =
|
|
cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate;
|
|
|
|
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
|
|
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
|
|
|
|
const confirmation = confirmations.get(cursor) ?? null;
|
|
found.push({
|
|
recurringId: cost.id,
|
|
date: cursor,
|
|
amount: cost.amount,
|
|
confirmedBy: confirmation?.id ?? null,
|
|
});
|
|
|
|
// No rebasing: the grid never moves off a confirmation's date. (The
|
|
// `rebasedFrom` this used to call through returned `cursor` unchanged for
|
|
// `calendar` anyway, so dropping it changes nothing here.)
|
|
const next = stepFrom(cursor, cost.recurrence, cost.anchoring);
|
|
|
|
// The loop terminates only if the step strictly increases. `n >= 1` is
|
|
// enforced at both write boundaries, so reaching this is a bug rather than
|
|
// bad input — and a hang is the one failure mode worse than a visible
|
|
// error. Never soften this into a break: silently dropping a bill would
|
|
// put a wrong number on the screen, which AGENTS.md forbids outright.
|
|
if (!isBefore(cursor, next)) {
|
|
throw new Error(
|
|
`Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` +
|
|
`A recurrence must move strictly forward; check that n is at least 1.`,
|
|
);
|
|
}
|
|
cursor = next;
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Every cost's occurrences through `upToMonth`, grouped by the month they land
|
|
* in. The fold calls this once and reads a month at a time.
|
|
*/
|
|
export function occurrencesByMonth(
|
|
dataset: Dataset,
|
|
upToMonth: MonthId,
|
|
): Map<MonthId, Occurrence[]> {
|
|
const byMonth = new Map<MonthId, Occurrence[]>();
|
|
|
|
for (const cost of dataset.recurring) {
|
|
for (const occurrence of occurrencesOf(cost, dataset.purchases, upToMonth)) {
|
|
const month = monthOf(occurrence.date);
|
|
const existing = byMonth.get(month);
|
|
if (existing) existing.push(occurrence);
|
|
else byMonth.set(month, [occurrence]);
|
|
}
|
|
}
|
|
|
|
return byMonth;
|
|
}
|