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:
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user