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