feat(domain): add month, year, and summary view models
All three aggregate over foldBalances rather than recomputing rollover.
This commit is contained in:
1 parent
4077036ca7
commit
564a5f4079
2 files changed
+306
No files matched your search
@@ -0,0 +1,129 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { monthView, yearView, summaryView } from "./views.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
function post(id: string, order: number, percent: number, archived = false): Post {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
order,
|
||||
archived,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "percentOfIncome", percent },
|
||||
};
|
||||
}
|
||||
|
||||
function spend(id: string, postId: string, amount: number, date: string): Purchase {
|
||||
return {
|
||||
id,
|
||||
date,
|
||||
description: id,
|
||||
total: { amount, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId, value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
};
|
||||
}
|
||||
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
fxRates: [],
|
||||
posts: [post("food", 0, 20), post("games", 1, 10)],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
|
||||
{
|
||||
id: "2026-02",
|
||||
income: { amount: 10000, currency: "DKK" },
|
||||
ruleOverrides: { games: { kind: "fixed", amount: { amount: 2000, currency: "DKK" } } },
|
||||
},
|
||||
],
|
||||
purchases: [spend("a", "food", 500, "2026-01-10"), spend("b", "games", 300, "2026-02-10")],
|
||||
};
|
||||
|
||||
describe("monthView", () => {
|
||||
test("reports income, allocations, and the unallocated remainder", () => {
|
||||
const view = monthView(data, "2026-01");
|
||||
expect(view.income).toBe(10000);
|
||||
expect(view.totalAllocation).toBe(3000);
|
||||
expect(view.totalCharges).toBe(500);
|
||||
expect(view.unallocated).toBe(7000);
|
||||
});
|
||||
|
||||
test("rows are ordered by post order and carry their figures", () => {
|
||||
const view = monthView(data, "2026-01");
|
||||
expect(view.rows.map((r) => r.post.id)).toEqual(["food", "games"]);
|
||||
expect(view.rows[0]!.figures.charges).toBe(500);
|
||||
expect(view.rows[0]!.figures.remaining).toBe(1500);
|
||||
});
|
||||
|
||||
test("flags rows whose rule is overridden this month", () => {
|
||||
const view = monthView(data, "2026-02");
|
||||
expect(view.rows.find((r) => r.post.id === "games")!.overridden).toBe(true);
|
||||
expect(view.rows.find((r) => r.post.id === "food")!.overridden).toBe(false);
|
||||
});
|
||||
|
||||
test("unallocated goes negative when percentages exceed income", () => {
|
||||
const greedy: Dataset = { ...data, posts: [post("food", 0, 80), post("games", 1, 40)] };
|
||||
expect(monthView(greedy, "2026-01").unallocated).toBe(-2000);
|
||||
});
|
||||
|
||||
test("archived posts appear only when they have activity", () => {
|
||||
const withArchived: Dataset = {
|
||||
...data,
|
||||
posts: [...data.posts, post("old", 2, 0, true)],
|
||||
};
|
||||
expect(monthView(withArchived, "2026-01").rows.map((r) => r.post.id)).toEqual([
|
||||
"food",
|
||||
"games",
|
||||
]);
|
||||
|
||||
const withActivity: Dataset = {
|
||||
...withArchived,
|
||||
purchases: [...data.purchases, spend("c", "old", 50, "2026-01-11")],
|
||||
};
|
||||
expect(monthView(withActivity, "2026-01").rows.map((r) => r.post.id)).toEqual([
|
||||
"food",
|
||||
"games",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("yearView", () => {
|
||||
test("returns twelve months with per-post totals and a closing balance", () => {
|
||||
const view = yearView(data, 2026);
|
||||
expect(view.months).toHaveLength(12);
|
||||
expect(view.incomeByMonth[0]).toBe(10000);
|
||||
expect(view.incomeByMonth[2]).toBe(0);
|
||||
expect(view.totalIncome).toBe(20000);
|
||||
|
||||
const food = view.rows.find((r) => r.post.id === "food")!;
|
||||
expect(food.byMonth).toHaveLength(12);
|
||||
expect(food.totalCharges).toBe(500);
|
||||
expect(food.totalAllocation).toBe(4000);
|
||||
expect(food.closingBalance).toBe(3500);
|
||||
});
|
||||
|
||||
test("the closing balance is December's remaining, carrying all year", () => {
|
||||
const games = yearView(data, 2026).rows.find((r) => r.post.id === "games")!;
|
||||
expect(games.totalAllocation).toBe(3000);
|
||||
expect(games.closingBalance).toBe(2700);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryView", () => {
|
||||
test("totals charges by post and by month across a range", () => {
|
||||
const view = summaryView(data, "2026-01", "2026-02");
|
||||
expect(view.totalCharges).toBe(800);
|
||||
expect(view.totalIncome).toBe(20000);
|
||||
expect(view.byPost.find((r) => r.post.id === "food")!.charges).toBe(500);
|
||||
expect(view.byMonth).toHaveLength(2);
|
||||
expect(view.byMonth[1]!.charges).toBe(300);
|
||||
});
|
||||
|
||||
test("an empty range totals zero", () => {
|
||||
const view = summaryView(data, "2026-05", "2026-04");
|
||||
expect(view.totalCharges).toBe(0);
|
||||
expect(view.byMonth).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { incomeFor, isOverridden } from "./allocation.ts";
|
||||
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { monthRange, monthsOfYear } from "./months.ts";
|
||||
import type { Dataset, MonthId, Post } from "./types.ts";
|
||||
|
||||
export interface MonthPostRow {
|
||||
post: Post;
|
||||
figures: PostMonthFigures;
|
||||
overridden: boolean;
|
||||
}
|
||||
|
||||
export interface MonthViewModel {
|
||||
monthId: MonthId;
|
||||
income: number;
|
||||
totalAllocation: number;
|
||||
totalCharges: number;
|
||||
/** income - totalAllocation. Negative when allocations exceed income. */
|
||||
unallocated: number;
|
||||
rows: MonthPostRow[];
|
||||
}
|
||||
|
||||
function hasActivity(figures: PostMonthFigures): boolean {
|
||||
return (
|
||||
figures.carriedIn !== 0 || figures.allocation !== 0 || figures.charges !== 0
|
||||
);
|
||||
}
|
||||
|
||||
function visiblePosts(dataset: Dataset): Post[] {
|
||||
return [...dataset.posts].sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
||||
const fold = foldBalances(dataset, monthId);
|
||||
const month = dataset.months.find((m) => m.id === monthId);
|
||||
const income = incomeFor(dataset, monthId);
|
||||
|
||||
const rows: MonthPostRow[] = [];
|
||||
for (const post of visiblePosts(dataset)) {
|
||||
const figures = figuresFor(fold, post.id, monthId);
|
||||
// Archived posts stay visible only while they still have activity.
|
||||
if (post.archived && !hasActivity(figures)) continue;
|
||||
rows.push({ post, figures, overridden: isOverridden(month, post.id) });
|
||||
}
|
||||
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const totalAllocation = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
|
||||
base,
|
||||
);
|
||||
const totalCharges = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.charges, 0),
|
||||
base,
|
||||
);
|
||||
|
||||
return {
|
||||
monthId,
|
||||
income,
|
||||
totalAllocation,
|
||||
totalCharges,
|
||||
unallocated: roundMoney(income - totalAllocation, base),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export interface YearPostRow {
|
||||
post: Post;
|
||||
/** Twelve entries, January to December. */
|
||||
byMonth: PostMonthFigures[];
|
||||
totalAllocation: number;
|
||||
totalCharges: number;
|
||||
/** December's remaining balance. */
|
||||
closingBalance: number;
|
||||
}
|
||||
|
||||
export interface YearViewModel {
|
||||
year: number;
|
||||
months: MonthId[];
|
||||
incomeByMonth: number[];
|
||||
totalIncome: number;
|
||||
totalCharges: number;
|
||||
rows: YearPostRow[];
|
||||
}
|
||||
|
||||
export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
const months = monthsOfYear(year);
|
||||
const fold = foldBalances(dataset, months[11]!);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
|
||||
|
||||
const rows: YearPostRow[] = visiblePosts(dataset).map((post) => {
|
||||
const byMonth = months.map((m) => figuresFor(fold, post.id, m));
|
||||
return {
|
||||
post,
|
||||
byMonth,
|
||||
totalAllocation: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.allocation, 0),
|
||||
base,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.charges, 0),
|
||||
base,
|
||||
),
|
||||
closingBalance: byMonth[11]!.remaining,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
year,
|
||||
months,
|
||||
incomeByMonth,
|
||||
totalIncome: roundMoney(
|
||||
incomeByMonth.reduce((a, b) => a + b, 0),
|
||||
base,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.totalCharges, 0),
|
||||
base,
|
||||
),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SummaryViewModel {
|
||||
from: MonthId;
|
||||
to: MonthId;
|
||||
byPost: { post: Post; charges: number }[];
|
||||
byMonth: { monthId: MonthId; charges: number }[];
|
||||
totalCharges: number;
|
||||
totalIncome: number;
|
||||
}
|
||||
|
||||
export function summaryView(
|
||||
dataset: Dataset,
|
||||
from: MonthId,
|
||||
to: MonthId,
|
||||
): SummaryViewModel {
|
||||
const months = monthRange(from, to);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const fold: Fold =
|
||||
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
|
||||
|
||||
const byPost = visiblePosts(dataset).map((post) => ({
|
||||
post,
|
||||
charges: roundMoney(
|
||||
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
|
||||
base,
|
||||
),
|
||||
}));
|
||||
|
||||
const byMonth = months.map((monthId) => ({
|
||||
monthId,
|
||||
charges: roundMoney(
|
||||
dataset.posts.reduce(
|
||||
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
|
||||
0,
|
||||
),
|
||||
base,
|
||||
),
|
||||
}));
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
byPost,
|
||||
byMonth,
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, m) => sum + m.charges, 0),
|
||||
base,
|
||||
),
|
||||
totalIncome: roundMoney(
|
||||
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
|
||||
base,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user