diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts new file mode 100644 index 0000000..e8b0df5 --- /dev/null +++ b/src/domain/fold.test.ts @@ -0,0 +1,149 @@ +import { test, expect, describe } from "bun:test"; +import { foldBalances, figuresFor } from "./fold.ts"; +import type { Dataset, Post, Purchase } from "./types.ts"; + +function post(id: string, fixed: number, archived = false): Post { + return { + id, + name: id, + order: 0, + archived, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } }, + }; +} + +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, + }; +} + +function dataset(posts: Post[], purchases: Purchase[]): Dataset { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: [], + posts, + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-02", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-03", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + ], + purchases, + }; +} + +describe("envelope rollover", () => { + test("the first month carries in zero", () => { + const fold = foldBalances(dataset([post("food", 500)], []), "2026-01"); + expect(figuresFor(fold, "food", "2026-01")).toEqual({ + carriedIn: 0, + allocation: 500, + charges: 0, + remaining: 500, + }); + }); + + test("unspent allocation carries into the next month", () => { + const data = dataset([post("food", 500)], [spend("a", "food", 300, "2026-01-05")]); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(200); + expect(figuresFor(fold, "food", "2026-02")).toEqual({ + carriedIn: 200, + allocation: 500, + charges: 0, + remaining: 700, + }); + }); + + test("overspend carries forward as debt", () => { + const data = dataset([post("food", 500)], [spend("a", "food", 700, "2026-01-05")]); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(-200); + expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(-200); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(300); + }); + + test("the chain accumulates across three months", () => { + const data = dataset( + [post("food", 500)], + [spend("a", "food", 100, "2026-01-05"), spend("b", "food", 900, "2026-02-05")], + ); + const fold = foldBalances(data, "2026-03"); + expect(figuresFor(fold, "food", "2026-01").remaining).toBe(400); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(0); + expect(figuresFor(fold, "food", "2026-03").remaining).toBe(500); + }); + + test("editing an early purchase changes every later month", () => { + const before = foldBalances( + dataset([post("food", 500)], [spend("a", "food", 100, "2026-01-05")]), + "2026-03", + ); + const after = foldBalances( + dataset([post("food", 500)], [spend("a", "food", 200, "2026-01-05")]), + "2026-03", + ); + expect(figuresFor(before, "food", "2026-03").remaining).toBe(1400); + expect(figuresFor(after, "food", "2026-03").remaining).toBe(1300); + }); +}); + +test("archived posts are still folded", () => { + const data = dataset( + [post("food", 500), post("old", 100, true)], + [spend("a", "old", 40, "2026-01-05")], + ); + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "old", "2026-02").carriedIn).toBe(60); +}); + +test("percentage allocations follow the month's income", () => { + const data: Dataset = { + ...dataset([], []), + posts: [ + { + id: "food", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + standingRule: { kind: "percentOfIncome", percent: 10 }, + }, + ], + months: [ + { id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-02", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, + ], + }; + const fold = foldBalances(data, "2026-02"); + expect(figuresFor(fold, "food", "2026-01").allocation).toBe(1000); + expect(figuresFor(fold, "food", "2026-02").allocation).toBe(2000); + expect(figuresFor(fold, "food", "2026-02").remaining).toBe(3000); +}); + +test("months before foldStartMonth contribute nothing", () => { + const data: Dataset = { + ...dataset([post("food", 500)], [spend("a", "food", 5000, "2025-06-01")]), + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + }; + const fold = foldBalances(data, "2026-01"); + expect(fold.has("2025-06")).toBe(false); + expect(figuresFor(fold, "food", "2026-01").carriedIn).toBe(0); +}); + +test("figuresFor returns zeroes for a month outside the fold", () => { + const fold = foldBalances(dataset([post("food", 500)], []), "2026-01"); + expect(figuresFor(fold, "food", "2030-01")).toEqual({ + carriedIn: 0, + allocation: 0, + charges: 0, + remaining: 0, + }); +}); diff --git a/src/domain/fold.ts b/src/domain/fold.ts new file mode 100644 index 0000000..e14311f --- /dev/null +++ b/src/domain/fold.ts @@ -0,0 +1,71 @@ +import { allocationFor } from "./allocation.ts"; +import { chargesForMonth } from "./charges.ts"; +import { roundMoney } from "./money.ts"; +import { compareMonths, monthRange } from "./months.ts"; +import type { Dataset, MonthId, PostId } from "./types.ts"; + +export interface PostMonthFigures { + carriedIn: number; + allocation: number; + charges: number; + /** carriedIn + allocation - charges. Negative means overspent. */ + remaining: number; +} + +export const EMPTY_FIGURES: PostMonthFigures = { + carriedIn: 0, + allocation: 0, + charges: 0, + remaining: 0, +}; + +export type Fold = Map>; + +/** + * The single implementation of envelope rollover. Year and summary views + * aggregate over this — they never recompute the math. + * + * Unspent allocation carries into the next month; overspend carries forward as + * a negative balance. Archived posts are included, since their history still + * has to fold. + */ +export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { + const { foldStartMonth, baseCurrency } = dataset.settings; + const fold: Fold = new Map(); + + if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; + + const carried = new Map(); + + for (const monthId of monthRange(foldStartMonth, upToMonth)) { + const charges = chargesForMonth(dataset, monthId); + const monthFigures = new Map(); + + for (const post of dataset.posts) { + const carriedIn = carried.get(post.id) ?? 0; + const allocation = allocationFor(dataset, post.id, monthId); + const spent = charges.get(post.id) ?? 0; + const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency); + + monthFigures.set(post.id, { + carriedIn, + allocation, + charges: spent, + remaining, + }); + carried.set(post.id, remaining); + } + + fold.set(monthId, monthFigures); + } + + return fold; +} + +export function figuresFor( + fold: Fold, + postId: PostId, + monthId: MonthId, +): PostMonthFigures { + return fold.get(monthId)?.get(postId) ?? EMPTY_FIGURES; +}