Files
budget2.0/src/domain/budgetTree.test.ts
T
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:51:17 +02:00

190 lines
9.0 KiB
TypeScript

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);
});