Files
budget2.0/src/domain/budgetTree.ts
T

88 lines
3.3 KiB
TypeScript

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