feat: derive folder bill totals and mixed tree
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user