Unspent carries forward, overspend carries as debt. This is the single implementation of the rollover math.
150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
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,
|
|
});
|
|
});
|