feat: derive folder bill totals and mixed tree
This commit is contained in:
1 parent
390abf2be4
commit
1e30671d76
5 files changed
+274
-11
No files matched your search
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user