feat: derive folder bill totals and mixed tree

This commit is contained in:
Lucas Winther committed 2026-09-29 20:41:38 +02:00
1 parent 390abf2be4
commit 1e30671d76
5 files changed
+274 -11

No files matched your search

+124
View File
@@ -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]);
});