feat(domain): add the envelope rollover fold

Unspent carries forward, overspend carries as debt. This is the single
implementation of the rollover math.
This commit is contained in:
Lucas Winther
2026-09-01 19:45:31 +02:00
parent 71d69c20b3
commit 4077036ca7
2 changed files with 220 additions and 0 deletions
+149
View File
@@ -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,
});
});
+71
View File
@@ -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<MonthId, Map<PostId, PostMonthFigures>>;
/**
* 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<PostId, number>();
for (const monthId of monthRange(foldStartMonth, upToMonth)) {
const charges = chargesForMonth(dataset, monthId);
const monthFigures = new Map<PostId, PostMonthFigures>();
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;
}