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]); }); test("a placed purchase is its own row while its post still carries the charge", () => { const data = dataset(); data.folders = [folder("housing", null)]; data.posts = [post("rent", "housing")]; data.purchases = [{ id: "paid", date: "2026-09", description: "Deposit", total: { amount: 100, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], schedule: null, placement: { folderId: "housing", order: 1 } }]; const housing = tree(data).roots[0]!; if (housing.kind !== "folder") throw new Error("Missing folder"); expect(housing.children.map(node => node.kind)).toEqual(["post", "purchase"]); expect(monthView(data, "2026-09").rows[0]!.figures.charges).toBe(100); expect(tree(data, "2026-10").roots[0]).toMatchObject({ kind: "folder", children: [{ kind: "post" }] }); }); test("unassigned purchases appear in one Budget group for their slice month", () => { const data = dataset(); data.folders = [folder("housing", null)]; data.purchases = [ { id: "loose", date: "2026-09", description: "Loose", total: { amount: 25, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null }, { id: "placed", date: "2026-09", description: "Placed", total: { amount: 40, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null, placement: { folderId: "housing", order: 0 } }, ]; const september = tree(data); expect(september.unassigned.map(node => node.kind === "purchase" ? node.purchase.id : "other")).toEqual(["loose", "placed"]); expect(september.roots[0]).toMatchObject({ kind: "folder", children: [] }); expect(tree(data, "2026-10").unassigned).toEqual([]); }); test("a bill nested under its post follows that post into another folder", () => { const data = dataset(); data.folders = [folder("housing", null), folder("other", null, 1)]; data.posts = [post("rent", "housing")]; data.recurring = [{ ...bill("rent-bill", null), parentPostId: "rent" }]; const first = tree(data).roots[0]!; if (first.kind !== "folder") throw new Error("Missing folder"); expect(first.billsDue).toBe(8000); expect(first.children[0]).toMatchObject({ kind: "post", children: [{ kind: "bill" }] }); data.posts[0]!.folderId = "other"; const second = tree(data).roots[1]!; if (second.kind !== "folder") throw new Error("Missing folder"); expect(second.billsDue).toBe(8000); }); test("an anchored bill's charges this month count toward Bills due, at their actual price", () => { // An anchored cost has no confirmed occurrences — a charge is a Purchase, and // the projection moves on to one cycle after it. Counting occurrences alone // would read a phone plan charged twice in September as 0 due there. const data = dataset(); data.folders = [folder("utilities", null)]; data.posts = [post("rent", null)]; data.recurring = [{ ...bill("phone", "utilities", 250), startDate: "2026-09-01", anchoring: "lastCharge" }]; const charge = (id: string, date: string, amount: number): Purchase => ({ id, date, description: "Phone", total: { amount, currency: "DKK" }, splitMode: "percent", splits: [{ postId: "rent", value: 100, absorbsRemainder: true }], schedule: null, source: { recurringId: "phone" }, }); data.purchases = [charge("a", "2026-09-05", 250), charge("b", "2026-09-20", 300)]; const utilities = tree(data).roots[0]!; if (utilities.kind !== "folder") throw new Error("expected a folder"); expect(utilities.billsDue).toBe(550); const phone = utilities.children[0]!; if (phone.kind !== "bill") throw new Error("expected a bill"); expect(phone.nextOccurrence?.date).toBe("2026-10-20"); const october = tree(data, "2026-10").roots[0]!; if (october.kind !== "folder") throw new Error("expected a folder"); expect(october.billsDue).toBe(250); });