feat: derive folder bill totals and mixed tree
This commit is contained in:
1 parent
390abf2be4
commit
1e30671d76
5 files changed
+274
-11
No files matched your search
@@ -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"),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user