From 1e30671d76bbd7c6535c868fefd53b57e62c51be Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 29 Sep 2026 20:41:38 +0200 Subject: [PATCH] feat: derive folder bill totals and mixed tree --- src/domain/budgetTree.test.ts | 124 +++++++++++++++++++++++++++++++++ src/domain/budgetTree.ts | 87 +++++++++++++++++++++++ src/domain/index.ts | 1 + src/domain/occurrences.test.ts | 30 +++++++- src/domain/occurrences.ts | 43 +++++++++--- 5 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 src/domain/budgetTree.test.ts create mode 100644 src/domain/budgetTree.ts diff --git a/src/domain/budgetTree.test.ts b/src/domain/budgetTree.test.ts new file mode 100644 index 0000000..bdd9a19 --- /dev/null +++ b/src/domain/budgetTree.test.ts @@ -0,0 +1,124 @@ +import { expect, test } from "bun:test"; +import { budgetTree } from "./budgetTree.ts"; +import { MissingRateError } from "./fx.ts"; +import { createSeedDataset } from "./seed.ts"; +import type { BudgetFolder, Dataset, Post, Purchase, RecurringCost } from "./types.ts"; +import { monthView } from "./views.ts"; + +const post = (id: string, folderId: string | null, order = 0): Post => ({ + id, name: id, order, folderId, archived: false, currency: "DKK", rules: [], +}); +const folder = (id: string, parentId: string | null, order = 0): BudgetFolder => ({ id, name: id, parentId, order }); +const bill = (id: string, folderId: string | null, amount = 8000, order = 1): RecurringCost => ({ + id, name: id, order, folderId, archived: false, + amount: { amount, currency: "DKK" }, startDate: "2026-09", + recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", + splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], +}); +function dataset(): Dataset { + const data = createSeedDataset("2026-09"); + data.posts = []; + data.months = [{ id: "2026-09", income: { amount: 0, currency: "DKK" }, ruleOverrides: {} }]; + return data; +} +const tree = (data: Dataset, month = "2026-09") => budgetTree(data, month, monthView(data, month).rows); + +test("Housing counts rent once", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", "housing")]; + data.recurring = [bill("rent-bill", "housing")]; + const housing = tree(data).roots[0]!; + expect(housing.kind).toBe("folder"); + if (housing.kind !== "folder") return; + expect(housing.billsDue).toBe(8000); + expect(housing.children.map(node => node.kind)).toEqual(["post", "bill"]); + expect(housing.children[1]!.kind).toBe("bill"); +}); + +test("nested totals follow bill placement", () => { + const data = dataset(); + data.folders = [folder("housing", null), folder("utilities", "housing")]; + data.posts = [post("rent", null)]; + data.recurring = [bill("phone", "utilities", 250)]; + const housing = tree(data).roots[0]!; + expect(housing.kind).toBe("folder"); + if (housing.kind !== "folder") return; + expect(housing.billsDue).toBe(250); + expect(housing.children[0]!.kind).toBe("folder"); + const utilities = housing.children[0]!; + if (utilities.kind !== "folder") return; + expect(utilities.billsDue).toBe(250); + expect(tree(data).unfiled.map(node => node.kind)).toEqual(["post"]); +}); + +test("confirmed off-schedule slot uses actual price", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", "housing")]; + data.recurring = [bill("rent-bill", "housing")]; + const purchase: Purchase = { + id: "paid", date: "2026-10-02", description: "Rent", + total: { amount: 7900, currency: "DKK" }, splitMode: "percent", + splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], + schedule: null, source: { recurringId: "rent-bill", occurrenceDate: "2026-09" }, + }; + data.purchases = [purchase]; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(7900); + expect(monthView(data, "2026-10").rows[0]!.figures.charges).toBe(7900); +}); + +test("two 28-day slots contribute twice", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("twice", "housing", 50)]; + data.recurring[0]!.startDate = "2026-09-01"; + data.recurring[0]!.recurrence = { kind: "everyNDays", n: 28 }; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(100); + expect(housing.children[0]!.kind).toBe("bill"); + const node = housing.children[0]!; + if (node.kind === "bill") expect(node.occurrences.map(o => o.date)).toEqual(["2026-09-01", "2026-09-29"]); +}); + +test("FX rounds each price at dataset digits zero", () => { + const data = dataset(); + data.settings.digits = 0; + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("fx", "housing", 10.6)]; + data.recurring[0]!.amount.currency = "EUR"; + data.fxRates = [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-09-01", source: "manual" }]; + const housing = tree(data).roots[0]!; + if (housing.kind !== "folder") throw new Error("Missing housing folder"); + expect(housing.billsDue).toBe(80); +}); + +test("negative prices subtract from Bills due", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("refund", "housing", -25)]; + const housing = tree(data).roots[0]!; + if (housing.kind === "folder") expect(housing.billsDue).toBe(-25); +}); + +test("missing FX surfaces an error", () => { + const data = dataset(); + data.folders = [folder("housing", null)]; + data.posts = [post("rent", null)]; + data.recurring = [bill("fx", "housing")]; + data.recurring[0]!.amount.currency = "EUR"; + expect(() => budgetTree(data, "2026-09", [])).toThrow(MissingRateError); +}); + +test("empty and post-only folders have zero Bills due", () => { + const data = dataset(); + data.folders = [folder("empty", null), folder("post-only", null, 1)]; + data.posts = [post("rent", "post-only")]; + expect(tree(data).roots.map(node => node.kind === "folder" ? node.billsDue : NaN)).toEqual([0, 0]); +}); diff --git a/src/domain/budgetTree.ts b/src/domain/budgetTree.ts new file mode 100644 index 0000000..b0f424b --- /dev/null +++ b/src/domain/budgetTree.ts @@ -0,0 +1,87 @@ +import { toBase } from "./fx.ts"; +import { roundMoney } from "./money.ts"; +import { + nextUnconfirmedOccurrenceOnOrAfter, + occurrencesByMonth, + type Occurrence, +} from "./occurrences.ts"; +import type { BudgetFolder, Dataset, MonthId, RecurringCost } from "./types.ts"; +import type { MonthPostRow } from "./views.ts"; + +export type BudgetNode = + | { kind: "folder"; folder: BudgetFolder; billsDue: number; children: BudgetNode[] } + | { kind: "post"; row: MonthPostRow } + | { kind: "bill"; cost: RecurringCost; occurrences: Occurrence[]; nextOccurrence: Occurrence | null }; + +export interface BudgetTreeView { + roots: BudgetNode[]; + unfiled: BudgetNode[]; +} + +/** Pure month-specific placement and scheduled bill prices. */ +export function budgetTree( + dataset: Dataset, + monthId: MonthId, + rows: readonly MonthPostRow[], +): BudgetTreeView { + const slots = occurrencesByMonth(dataset, monthId).get(monthId) ?? []; + const purchases = new Map(dataset.purchases.map(purchase => [purchase.id, purchase])); + const slotsByBill = new Map(); + for (const slot of slots) { + const found = slotsByBill.get(slot.recurringId); + if (found) found.push(slot); + else slotsByBill.set(slot.recurringId, [slot]); + } + + type OrderedNode = { order: number; node: BudgetNode }; + const byParent = new Map(); + const add = (parent: string | null, order: number, node: BudgetNode): void => { + const found = byParent.get(parent); + if (found) found.push({ order, node }); + else byParent.set(parent, [{ order, node }]); + }; + + for (const folder of dataset.folders) { + add(folder.parentId, folder.order, { kind: "folder", folder, billsDue: 0, children: [] }); + } + for (const row of rows) { + add(row.post.folderId, row.post.order, { kind: "post", row }); + } + for (const cost of dataset.recurring) { + add(cost.folderId, cost.order, { + kind: "bill", + cost, + occurrences: slotsByBill.get(cost.id) ?? [], + nextOccurrence: nextUnconfirmedOccurrenceOnOrAfter(dataset, cost.id, monthId), + }); + } + + const digits = dataset.settings.digits; + const childrenOf = (parent: string | null): BudgetNode[] => + (byParent.get(parent) ?? []) + .sort((a, b) => a.order - b.order) + .map(({ node }) => { + if (node.kind === "folder") { + node.children = childrenOf(node.folder.id); + node.billsDue = node.children.reduce((sum, child) => + roundMoney(sum + (child.kind === "folder" ? child.billsDue : child.kind === "bill" ? billDue(child.occurrences) : 0), digits), 0); + } + return node; + }); + + const billDue = (occurrences: readonly Occurrence[]): number => + occurrences.reduce((sum, occurrence) => { + const purchase = occurrence.confirmedBy ? purchases.get(occurrence.confirmedBy) : null; + if (occurrence.confirmedBy && !purchase) { + throw new Error(`Missing confirming purchase: ${occurrence.confirmedBy}`); + } + const price = purchase?.total ?? occurrence.amount; + return roundMoney(sum + toBase(price, dataset.settings.baseCurrency, dataset.fxRates, digits), digits); + }, 0); + + const rootItems = childrenOf(null); + return { + roots: rootItems.filter(node => node.kind === "folder"), + unfiled: rootItems.filter(node => node.kind !== "folder"), + }; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index ed838dc..7104a7e 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -10,5 +10,6 @@ export * from "./charges.ts"; export * from "./occurrences.ts"; export * from "./fold.ts"; export * from "./views.ts"; +export * from "./budgetTree.ts"; export * from "./seed.ts"; export * from "./plans.ts"; diff --git a/src/domain/occurrences.test.ts b/src/domain/occurrences.test.ts index 2660298..b55702f 100644 --- a/src/domain/occurrences.test.ts +++ b/src/domain/occurrences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts"; +import { nextUnconfirmedOccurrenceOnOrAfter, occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts"; import type { Dataset, Purchase, RecurringCost } from "./types.ts"; function cost(overrides: Partial = {}): RecurringCost { @@ -287,3 +287,31 @@ describe("occurrencesByMonth", () => { expect(byMonth.has("2026-02")).toBe(false); }); }); + +test("next slot exceeds preview horizon", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], folders: [], posts: [], months: [], purchases: [], + recurring: [cost({ startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 24 } })], + }; + expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")?.date).toBe("2028-01"); + data.recurring[0]!.endedFrom = "2027-01"; + expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")).toBeNull(); +}); + +test("next-slot lookup rejects a recurrence that does not advance", () => { + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 }, + currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }], + fxRates: [], folders: [], posts: [], months: [], purchases: [], + recurring: [cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 0 } })], + }; + expect(() => nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-01")).toThrow(/did not advance/); +}); + +test("bounded projection ignores invalid confirmations beyond its requested month", () => { + const phone = cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 30 }, anchoring: "lastCharge" }); + const future = new Map([["2026-03-02", confirmation("2026-03-02", "2025-11-02", "bad")]]); + expect(occurrencesOf(phone, future, "2026-02").map(slot => slot.date)).toEqual(["2026-01-01", "2026-01-31"]); +}); diff --git a/src/domain/occurrences.ts b/src/domain/occurrences.ts index 3462c44..f9d48ea 100644 --- a/src/domain/occurrences.ts +++ b/src/domain/occurrences.ts @@ -141,24 +141,26 @@ export function occurrencesOf( confirmations: ReadonlyMap, upToMonth: MonthId, ): Occurrence[] { - const found: Occurrence[] = []; + return [...occurrenceWalk(cost, confirmations, upToMonth)]; +} + +/** The same walk serves bounded month projections and unbounded next-slot lookup. */ +function* occurrenceWalk( + cost: RecurringCost, + confirmations: ReadonlyMap, + upToMonth?: MonthId, +): Generator { // 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) { + while (true) { + if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break; 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 @@ -194,10 +196,31 @@ export function occurrencesOf( : `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; } +} - return found; +/** 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; + const confirmations = confirmationsFor(dataset.purchases, recurringId); + for (const occurrence of occurrenceWalk(cost, confirmations)) { + if (compareMonths(monthOf(occurrence.date), monthId) >= 0 && !occurrence.confirmedBy) { + return occurrence; + } + } + return null; } /**