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]>
190 lines
9.0 KiB
TypeScript
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);
|
|
});
|