Files
budget2.0/src/domain/occurrences.ts
T
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:51:17 +02:00

354 lines
14 KiB
TypeScript

import { addDays, addMonthsToDate, weekdayOf } from "./days.ts";
import { isDayGranularDate } from "./granularity.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;
/**
* 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.
*
* 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;
}
/**
* 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;
}
/** 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.
*
* 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.
*/
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 [...walkFor(cost, purchases, upToMonth)];
}
/** Picks the walk for the cost's anchoring. Unbounded when `upToMonth` is absent. */
function walkFor(
cost: RecurringCost,
purchases: readonly Purchase[],
upToMonth?: MonthId,
): Generator<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,
): Generator<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;
// 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), 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.`,
);
}
let cursor = anchor === null ? cost.startDate : stepFrom(anchor, cost.recurrence, cost.anchoring);
while (true) {
if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break;
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
yield {
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;
}
}
/**
* 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.
*
* Unbounded when `upToMonth` is absent, for the next-slot lookup.
*/
function* gridOccurrences(
cost: RecurringCost,
confirmations: ReadonlyMap<IsoDate, Purchase>,
upToMonth?: MonthId,
): Generator<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 (true) {
if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break;
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
const confirmation = confirmations.get(cursor) ?? null;
// No rebasing: the grid never moves off a confirmation's date. Only the
// anchored walk measures from when money moved, and it has no slots.
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.`,
);
}
yield {
recurringId: cost.id,
date: cursor,
amount: cost.amount,
confirmedBy: confirmation?.id ?? null,
};
cursor = next;
}
}
/** First still-open slot in or after the requested month, without a preview cutoff. */
export function nextUnconfirmedOccurrenceOnOrAfter(
dataset: Dataset,
recurringId: RecurringCostId,
monthId: MonthId,
): Occurrence | null {
const cost = dataset.recurring.find(item => item.id === recurringId);
if (!cost) return null;
// Through `walkFor`, not the grid walk directly: an anchored cost's next due
// date is one cycle after its latest charge, and the grid would show the
// slot-model answer the anchored walk exists to replace.
for (const occurrence of walkFor(cost, dataset.purchases)) {
if (compareMonths(monthOf(occurrence.date), monthId) >= 0 && !occurrence.confirmedBy) {
return occurrence;
}
}
return null;
}
/**
* 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;
}