feat: derive folder bill totals and mixed tree

This commit is contained in:
Lucas Winther
2026-09-29 20:41:38 +02:00
parent 390abf2be4
commit 1e30671d76
5 changed files with 274 additions and 11 deletions
+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]);
});
+87
View File
@@ -0,0 +1,87 @@
import { toBase } from "./fx.ts";
import { roundMoney } from "./money.ts";
import {
nextUnconfirmedOccurrenceOnOrAfter,
occurrencesByMonth,
type Occurrence,
} from "./occurrences.ts";
import type { BudgetFolder, Dataset, MonthId, RecurringCost } from "./types.ts";
import type { MonthPostRow } from "./views.ts";
export type BudgetNode =
| { kind: "folder"; folder: BudgetFolder; billsDue: number; children: BudgetNode[] }
| { kind: "post"; row: MonthPostRow }
| { kind: "bill"; cost: RecurringCost; occurrences: Occurrence[]; nextOccurrence: Occurrence | null };
export interface BudgetTreeView {
roots: BudgetNode[];
unfiled: BudgetNode[];
}
/** Pure month-specific placement and scheduled bill prices. */
export function budgetTree(
dataset: Dataset,
monthId: MonthId,
rows: readonly MonthPostRow[],
): BudgetTreeView {
const slots = occurrencesByMonth(dataset, monthId).get(monthId) ?? [];
const purchases = new Map(dataset.purchases.map(purchase => [purchase.id, purchase]));
const slotsByBill = new Map<string, Occurrence[]>();
for (const slot of slots) {
const found = slotsByBill.get(slot.recurringId);
if (found) found.push(slot);
else slotsByBill.set(slot.recurringId, [slot]);
}
type OrderedNode = { order: number; node: BudgetNode };
const byParent = new Map<string | null, OrderedNode[]>();
const add = (parent: string | null, order: number, node: BudgetNode): void => {
const found = byParent.get(parent);
if (found) found.push({ order, node });
else byParent.set(parent, [{ order, node }]);
};
for (const folder of dataset.folders) {
add(folder.parentId, folder.order, { kind: "folder", folder, billsDue: 0, children: [] });
}
for (const row of rows) {
add(row.post.folderId, row.post.order, { kind: "post", row });
}
for (const cost of dataset.recurring) {
add(cost.folderId, cost.order, {
kind: "bill",
cost,
occurrences: slotsByBill.get(cost.id) ?? [],
nextOccurrence: nextUnconfirmedOccurrenceOnOrAfter(dataset, cost.id, monthId),
});
}
const digits = dataset.settings.digits;
const childrenOf = (parent: string | null): BudgetNode[] =>
(byParent.get(parent) ?? [])
.sort((a, b) => a.order - b.order)
.map(({ node }) => {
if (node.kind === "folder") {
node.children = childrenOf(node.folder.id);
node.billsDue = node.children.reduce((sum, child) =>
roundMoney(sum + (child.kind === "folder" ? child.billsDue : child.kind === "bill" ? billDue(child.occurrences) : 0), digits), 0);
}
return node;
});
const billDue = (occurrences: readonly Occurrence[]): number =>
occurrences.reduce((sum, occurrence) => {
const purchase = occurrence.confirmedBy ? purchases.get(occurrence.confirmedBy) : null;
if (occurrence.confirmedBy && !purchase) {
throw new Error(`Missing confirming purchase: ${occurrence.confirmedBy}`);
}
const price = purchase?.total ?? occurrence.amount;
return roundMoney(sum + toBase(price, dataset.settings.baseCurrency, dataset.fxRates, digits), digits);
}, 0);
const rootItems = childrenOf(null);
return {
roots: rootItems.filter(node => node.kind === "folder"),
unfiled: rootItems.filter(node => node.kind !== "folder"),
};
}
+1
View File
@@ -10,5 +10,6 @@ export * from "./charges.ts";
export * from "./occurrences.ts";
export * from "./fold.ts";
export * from "./views.ts";
export * from "./budgetTree.ts";
export * from "./seed.ts";
export * from "./plans.ts";
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
import { nextUnconfirmedOccurrenceOnOrAfter, occurrencesByMonth, occurrencesOf, stepFrom } from "./occurrences.ts";
import type { Dataset, Purchase, RecurringCost } from "./types.ts";
function cost(overrides: Partial<RecurringCost> = {}): RecurringCost {
@@ -287,3 +287,31 @@ describe("occurrencesByMonth", () => {
expect(byMonth.has("2026-02")).toBe(false);
});
});
test("next slot exceeds preview horizon", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [], folders: [], posts: [], months: [], purchases: [],
recurring: [cost({ startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 24 } })],
};
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")?.date).toBe("2028-01");
data.recurring[0]!.endedFrom = "2027-01";
expect(nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-02")).toBeNull();
});
test("next-slot lookup rejects a recurrence that does not advance", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 8, digits: 2 },
currencies: [{ code: "DKK", symbol: "kr", name: "Danish krone" }],
fxRates: [], folders: [], posts: [], months: [], purchases: [],
recurring: [cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 0 } })],
};
expect(() => nextUnconfirmedOccurrenceOnOrAfter(data, "r1", "2026-01")).toThrow(/did not advance/);
});
test("bounded projection ignores invalid confirmations beyond its requested month", () => {
const phone = cost({ startDate: "2026-01-01", recurrence: { kind: "everyNDays", n: 30 }, anchoring: "lastCharge" });
const future = new Map([["2026-03-02", confirmation("2026-03-02", "2025-11-02", "bad")]]);
expect(occurrencesOf(phone, future, "2026-02").map(slot => slot.date)).toEqual(["2026-01-01", "2026-01-31"]);
});
+33 -10
View File
@@ -141,24 +141,26 @@ export function occurrencesOf(
confirmations: ReadonlyMap<IsoDate, Purchase>,
upToMonth: MonthId,
): Occurrence[] {
const found: Occurrence[] = [];
return [...occurrenceWalk(cost, confirmations, upToMonth)];
}
/** The same walk serves bounded month projections and unbounded next-slot lookup. */
function* occurrenceWalk(
cost: RecurringCost,
confirmations: ReadonlyMap<IsoDate, Purchase>,
upToMonth?: MonthId,
): Generator<Occurrence> {
// A monthly cost is month-granular throughout, even if its start date was
// typed with a day. Rent occurs IN a month.
let cursor =
cost.recurrence.kind === "everyNMonths" ? monthOf(cost.startDate) : cost.startDate;
while (compareMonths(monthOf(cursor), upToMonth) <= 0) {
while (true) {
if (upToMonth && compareMonths(monthOf(cursor), upToMonth) > 0) break;
if (cost.endedFrom && !isBefore(cursor, cost.endedFrom)) break;
const confirmation = confirmations.get(cursor) ?? null;
found.push({
recurringId: cost.id,
date: cursor,
amount: cost.amount,
confirmedBy: confirmation?.id ?? null,
});
// `lastCharge` measures from when the money ACTUALLY moved, which is what
// makes an early charge rebase the series. An unconfirmed occurrence steps
// from its own projected date: the projection assumes bills are paid on
@@ -194,10 +196,31 @@ export function occurrencesOf(
: `A recurrence must move strictly forward; check that n is at least 1.`),
);
}
yield {
recurringId: cost.id,
date: cursor,
amount: cost.amount,
confirmedBy: confirmation?.id ?? null,
};
cursor = next;
}
}
return found;
/** First still-open slot in or after the requested month, without a preview cutoff. */
export function nextUnconfirmedOccurrenceOnOrAfter(
dataset: Dataset,
recurringId: RecurringCostId,
monthId: MonthId,
): Occurrence | null {
const cost = dataset.recurring.find(item => item.id === recurringId);
if (!cost) return null;
const confirmations = confirmationsFor(dataset.purchases, recurringId);
for (const occurrence of occurrenceWalk(cost, confirmations)) {
if (compareMonths(monthOf(occurrence.date), monthId) >= 0 && !occurrence.confirmedBy) {
return occurrence;
}
}
return null;
}
/**