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>
354 lines
14 KiB
TypeScript
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;
|
|
}
|