From 564a5f40799bc073262429c59c34298d5b3b1ba7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 19:48:48 +0200 Subject: [PATCH] feat(domain): add month, year, and summary view models All three aggregate over foldBalances rather than recomputing rollover. --- src/domain/views.test.ts | 129 ++++++++++++++++++++++++++++ src/domain/views.ts | 177 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/domain/views.test.ts create mode 100644 src/domain/views.ts diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts new file mode 100644 index 0000000..e6b3e83 --- /dev/null +++ b/src/domain/views.test.ts @@ -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); + }); +}); diff --git a/src/domain/views.ts b/src/domain/views.ts new file mode 100644 index 0000000..e1b2ac0 --- /dev/null +++ b/src/domain/views.ts @@ -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, + ), + }; +}