88 lines
3.3 KiB
TypeScript
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"),
|
|
};
|
|
}
|