diff --git a/src/domain/index.ts b/src/domain/index.ts index f9804be..ed838dc 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -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"; diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts new file mode 100644 index 0000000..44aa658 --- /dev/null +++ b/src/domain/occurrences.test.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 { + 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(); + +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); + }); +}); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts new file mode 100644 index 0000000..443be4e --- /dev/null +++ b/src/domain/occurrences.ts @@ -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 { + const found = new Map(); + 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, + 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 { + const byMonth = new Map(); + + 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; +}