feat(domain): project recurring occurrences, never store them
A walk rather than a per-month query: under lastCharge anchoring, where a series sits in a given month depends on every confirmation before it, so the projection starts at startDate and steps forward. Bounded by upToMonth, which the caller supplies — the domain still reads no clock. The data-cap behaviour is one line. `from` is the confirmation's own date under lastCharge, so confirming a slot early moves every later occurrence with it. Confirm the 2026-01-31 slot with a purchase dated the 12th and the next lands on 2026-02-11, not 2026-03-02. The walk throws if a step fails to advance. n >= 1 is enforced at both write boundaries so this should be unreachable, but a hang is worse than a throw and dropping a bill silently is worse than both. Rewrote the "deleting the confirming purchase" test per controller ruling: the brief's version was byte-identical to the unconfirmed-series test above it and asserted nothing new. It now walks the phone cost with the capHit confirmations, then walks the same cost with an empty map, and asserts the dates differ and match the unconfirmed series — which is what "deleting the purchase restores the series" actually means. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a456aadf35
commit
b41b219d94
3 files changed
+440
No files matched your search
@@ -7,6 +7,7 @@ export * from "./days.ts";
|
||||
export * from "./fx.ts";
|
||||
export * from "./allocation.ts";
|
||||
export * from "./charges.ts";
|
||||
export * from "./occurrences.ts";
|
||||
export * from "./fold.ts";
|
||||
export * from "./views.ts";
|
||||
export * from "./seed.ts";
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
|
||||
import type { Dataset, Purchase, RecurringCost } from "./types.ts";
|
||||
|
||||
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
|
||||
return {
|
||||
id: "r1",
|
||||
name: "Rent",
|
||||
order: 0,
|
||||
archived: false,
|
||||
amount: { amount: 8000, currency: "DKK" },
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "calendar",
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function confirmation(occurrenceDate: string, paidOn: string, id = "p1"): Purchase {
|
||||
return {
|
||||
id,
|
||||
date: paidOn,
|
||||
description: "Rent",
|
||||
total: { amount: 8000, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
source: { recurringId: "r1", occurrenceDate },
|
||||
};
|
||||
}
|
||||
|
||||
const none = new Map<string, Purchase>();
|
||||
|
||||
describe("stepFrom", () => {
|
||||
test("everyNMonths steps whole months and stays month-granular", () => {
|
||||
expect(stepFrom("2026-01", { kind: "everyNMonths", n: 1 })).toBe("2026-02");
|
||||
expect(stepFrom("2026-11", { kind: "everyNMonths", n: 3 })).toBe("2027-02");
|
||||
});
|
||||
|
||||
test("everyNMonths from a day-granular date returns the month", () => {
|
||||
// Reachable under lastCharge anchoring, where the step runs from an actual
|
||||
// purchase date.
|
||||
expect(stepFrom("2026-01-17", { kind: "everyNMonths", n: 1 })).toBe("2026-02");
|
||||
});
|
||||
|
||||
test("everyNDays steps days", () => {
|
||||
expect(stepFrom("2026-01-05", { kind: "everyNDays", n: 28 })).toBe("2026-02-02");
|
||||
expect(stepFrom("2026-01-31", { kind: "everyNDays", n: 30 })).toBe("2026-03-02");
|
||||
});
|
||||
|
||||
test("everyNWeeks lands on the named weekday", () => {
|
||||
// 2026-09-03 is a Thursday (4). Two weeks on is 2026-09-17, also Thursday.
|
||||
expect(stepFrom("2026-09-03", { kind: "everyNWeeks", n: 2, weekday: 4 })).toBe("2026-09-17");
|
||||
});
|
||||
|
||||
test("everyNWeeks snaps forward when the cursor is off-weekday", () => {
|
||||
// Reachable under lastCharge: paid on a Saturday, but the series is Thursdays.
|
||||
// 2026-09-05 is a Saturday; +1 week is 2026-09-12 (Saturday), snapping
|
||||
// forward to the next Thursday gives 2026-09-17.
|
||||
expect(stepFrom("2026-09-05", { kind: "everyNWeeks", n: 1, weekday: 4 })).toBe("2026-09-17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — calendar anchoring", () => {
|
||||
test("a monthly cost yields one occurrence per month", () => {
|
||||
const dates = occurrencesOf(cost(), none, "2026-04").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02", "2026-03", "2026-04"]);
|
||||
});
|
||||
|
||||
test("a 28-day cost puts two occurrences in some months", () => {
|
||||
const subscription = cost({
|
||||
startDate: "2026-01-05",
|
||||
recurrence: { kind: "everyNDays", n: 28 },
|
||||
});
|
||||
const dates = occurrencesOf(subscription, none, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-05", "2026-02-02", "2026-03-02", "2026-03-30"]);
|
||||
});
|
||||
|
||||
test("nothing is produced before the start date", () => {
|
||||
const later = cost({ startDate: "2026-03" });
|
||||
expect(occurrencesOf(later, none, "2026-04").map((o) => o.date)).toEqual([
|
||||
"2026-03",
|
||||
"2026-04",
|
||||
]);
|
||||
});
|
||||
|
||||
test("endedFrom stops the series, and the boundary date itself is excluded", () => {
|
||||
const ended = cost({ endedFrom: "2026-03" });
|
||||
expect(occurrencesOf(ended, none, "2026-06").map((o) => o.date)).toEqual([
|
||||
"2026-01",
|
||||
"2026-02",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a confirmation does NOT move a calendar-anchored series", () => {
|
||||
const paidLate = new Map([["2026-01", confirmation("2026-01", "2026-01-28")]]);
|
||||
const dates = occurrencesOf(cost(), paidLate, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02", "2026-03"]);
|
||||
});
|
||||
|
||||
test("archived does not stop projection", () => {
|
||||
// Archiving is a UI flag. Stopping a bill is endedFrom, because archiving
|
||||
// must not retroactively remove a past expected charge.
|
||||
const dates = occurrencesOf(cost({ archived: true }), none, "2026-02").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01", "2026-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — confirmation", () => {
|
||||
test("a confirmed slot carries the purchase id", () => {
|
||||
const claimed = new Map([["2026-02", confirmation("2026-02", "2026-02", "pX")]]);
|
||||
const found = occurrencesOf(cost(), claimed, "2026-03");
|
||||
expect(found.map((o) => o.confirmedBy)).toEqual([null, "pX", null]);
|
||||
});
|
||||
|
||||
test("a slot is claimed by occurrenceDate, not by the purchase's own date", () => {
|
||||
// Paid on the 28th for the slot dated 2026-01. The slot is claimed; a new
|
||||
// one does not appear.
|
||||
const claimed = new Map([["2026-01", confirmation("2026-01", "2026-01-28", "pY")]]);
|
||||
const found = occurrencesOf(cost(), claimed, "2026-02");
|
||||
expect(found[0]!.confirmedBy).toBe("pY");
|
||||
expect(found[1]!.confirmedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — lastCharge anchoring: the phone bill", () => {
|
||||
const phone = cost({
|
||||
id: "r1",
|
||||
name: "Phone",
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
|
||||
test("unconfirmed, it behaves exactly like a calendar series", () => {
|
||||
// Each occurrence steps from its own projected date: the projection assumes
|
||||
// bills are paid on time.
|
||||
const dates = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
|
||||
test("hitting the data cap early rebases the whole series", () => {
|
||||
// The slot sits at 2026-01-31. The cap is hit on the 12th, so the owner
|
||||
// confirms THAT slot with a purchase dated 2026-01-12. The next occurrence
|
||||
// is 30 days after the 12th, not after the 31st.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
});
|
||||
|
||||
test("the cap can be hit twice", () => {
|
||||
const twice = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
["2026-02-11", confirmation("2026-02-11", "2026-01-20", "pC")],
|
||||
]);
|
||||
const dates = occurrencesOf(phone, twice, "2026-03").map((o) => o.date);
|
||||
expect(dates).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-02-19", "2026-03-21"]);
|
||||
});
|
||||
|
||||
test("deleting the confirming purchase un-confirms the slot and restores the series", () => {
|
||||
// CONTROLLER RULING: the brief's original version of this test was
|
||||
// byte-identical to "unconfirmed, it behaves exactly like a calendar
|
||||
// series" above and asserted nothing new. Rewritten to actually contrast
|
||||
// the two states: walk WITH the capHit confirmations (rebased dates),
|
||||
// then walk the SAME cost with an empty map (as if the confirming
|
||||
// purchase had been deleted) and assert the dates differ and match the
|
||||
// plain unconfirmed series.
|
||||
const capHit = new Map([
|
||||
["2026-01-01", confirmation("2026-01-01", "2026-01-01", "pA")],
|
||||
["2026-01-31", confirmation("2026-01-31", "2026-01-12", "pB")],
|
||||
]);
|
||||
const rebased = occurrencesOf(phone, capHit, "2026-03").map((o) => o.date);
|
||||
expect(rebased).toEqual(["2026-01-01", "2026-01-31", "2026-02-11", "2026-03-13"]);
|
||||
|
||||
// Deleting the confirming purchase leaves no entry in the confirmations
|
||||
// map for that slot — this is what an empty map models here, since the
|
||||
// slot in question (2026-01-31) is the only one whose confirmation
|
||||
// affects downstream dates.
|
||||
const afterDelete = occurrencesOf(phone, none, "2026-03").map((o) => o.date);
|
||||
expect(afterDelete).not.toEqual(rebased);
|
||||
expect(afterDelete).toEqual(["2026-01-01", "2026-01-31", "2026-03-02"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesOf — termination", () => {
|
||||
test("a confirmation dated absurdly early throws rather than walking backwards", () => {
|
||||
// The ONLY way a valid recurrence fails to advance. Under lastCharge the
|
||||
// step runs from the confirmation's own date, so a payment recorded more
|
||||
// than n days before the slot it claims would move the cursor backwards.
|
||||
// A visible error beats a fold that silently emits duplicate months.
|
||||
const phone = cost({
|
||||
startDate: "2026-01-01",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
const outOfOrder = new Map([
|
||||
["2026-01-31", confirmation("2026-01-31", "2025-11-02", "pBad")],
|
||||
]);
|
||||
expect(() => occurrencesOf(phone, outOfOrder, "2026-03")).toThrow(/did not advance/);
|
||||
});
|
||||
|
||||
test("a recurrence that cannot advance throws rather than hanging", () => {
|
||||
const broken = cost({ recurrence: { kind: "everyNDays", n: 0 }, startDate: "2026-01-01" });
|
||||
expect(() => occurrencesOf(broken, none, "2026-02")).toThrow(/did not advance/);
|
||||
});
|
||||
|
||||
test("a negative step throws too", () => {
|
||||
const backwards = cost({ recurrence: { kind: "everyNDays", n: -7 }, startDate: "2026-01-01" });
|
||||
expect(() => occurrencesOf(backwards, none, "2026-02")).toThrow(/did not advance/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("occurrencesByMonth", () => {
|
||||
function dataset(recurring: RecurringCost[], purchases: Purchase[] = []): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 7, digits: 2 },
|
||||
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
|
||||
fxRates: [],
|
||||
posts: [],
|
||||
months: [],
|
||||
purchases,
|
||||
recurring,
|
||||
};
|
||||
}
|
||||
|
||||
test("groups every cost's occurrences by the month they land in", () => {
|
||||
const byMonth = occurrencesByMonth(
|
||||
dataset([
|
||||
cost({ id: "rent", startDate: "2026-01" }),
|
||||
cost({ id: "wow", startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 28 } }),
|
||||
]),
|
||||
"2026-02",
|
||||
);
|
||||
|
||||
expect(byMonth.get("2026-01")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]);
|
||||
expect(byMonth.get("2026-02")!.map((o) => o.recurringId).sort()).toEqual(["rent", "wow"]);
|
||||
});
|
||||
|
||||
test("a purchase confirming one cost does not claim another cost's slot", () => {
|
||||
const shared = confirmation("2026-01", "2026-01", "pZ");
|
||||
const byMonth = occurrencesByMonth(
|
||||
dataset([cost({ id: "r1" }), cost({ id: "r2" })], [shared]),
|
||||
"2026-01",
|
||||
);
|
||||
const january = byMonth.get("2026-01")!;
|
||||
expect(january.find((o) => o.recurringId === "r1")!.confirmedBy).toBe("pZ");
|
||||
expect(january.find((o) => o.recurringId === "r2")!.confirmedBy).toBeNull();
|
||||
});
|
||||
|
||||
test("months with no occurrence have no entry at all", () => {
|
||||
const byMonth = occurrencesByMonth(dataset([cost({ endedFrom: "2026-02" })]), "2026-04");
|
||||
expect(byMonth.has("2026-01")).toBe(true);
|
||||
expect(byMonth.has("2026-02")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { addDays, weekdayOf } from "./days.ts";
|
||||
import { addMonths, compareMonths, monthOf } from "./months.ts";
|
||||
import type {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* 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): IsoDate {
|
||||
switch (recurrence.kind) {
|
||||
case "everyNMonths":
|
||||
// Month-granular in, month-granular out. `monthOf` also accepts a
|
||||
// day-granular date, which is what a lastCharge rebase supplies.
|
||||
return 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 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.
|
||||
*/
|
||||
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) {
|
||||
found.set(purchase.source.occurrenceDate, purchase);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every occurrence of one cost from its start through `upToMonth`, in order.
|
||||
*
|
||||
* A walk rather than a per-month query, because under `lastCharge` anchoring
|
||||
* where the series sits in a given month depends on every confirmation before
|
||||
* it. The walk always begins at `startDate` even when that precedes the fold
|
||||
* start, because the phase depends on the whole history; callers read only the
|
||||
* months they need. The cost is trivial — thirty-five years of a 28-day cycle
|
||||
* is about thirteen thousand integer additions.
|
||||
*
|
||||
* `upToMonth` comes from the caller. This module reads no clock, which is what
|
||||
* keeps `src/domain/` pure.
|
||||
*/
|
||||
export function occurrencesOf(
|
||||
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,
|
||||
});
|
||||
|
||||
// `lastCharge` measures from when the money ACTUALLY moved, which is what
|
||||
// makes an early charge rebase the series. An unconfirmed occurrence steps
|
||||
// from its own projected date: the projection assumes bills are paid on
|
||||
// time, and self-corrects as they are confirmed.
|
||||
const from =
|
||||
cost.anchoring === "lastCharge" && confirmation ? confirmation.date : cursor;
|
||||
const next = stepFrom(from, cost.recurrence);
|
||||
|
||||
// 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)) {
|
||||
// Two causes, and the message names both because they need different
|
||||
// fixes. With a valid `n >= 1` a CALENDAR series can never reach this:
|
||||
// every kind adds at least one day. Only `lastCharge` can, and only when
|
||||
// a confirmation is dated more than one step before the slot it claims.
|
||||
throw new Error(
|
||||
`Recurring cost "${cost.name}" did not advance past ${cursor} (produced ${next}). ` +
|
||||
(confirmation
|
||||
? `Purchase ${confirmation.id} claims that slot but is dated ${confirmation.date}, ` +
|
||||
`more than one step earlier — correct its date to move the series forward.`
|
||||
: `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) {
|
||||
const confirmations = confirmationsFor(dataset.purchases, cost.id);
|
||||
for (const occurrence of occurrencesOf(cost, confirmations, upToMonth)) {
|
||||
const month = monthOf(occurrence.date);
|
||||
const existing = byMonth.get(month);
|
||||
if (existing) existing.push(occurrence);
|
||||
else byMonth.set(month, [occurrence]);
|
||||
}
|
||||
}
|
||||
|
||||
return byMonth;
|
||||
}
|
||||
Reference in new issue
Block a user