Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts";
|
|
import type { Dataset, Post, Purchase } from "./types.ts";
|
|
|
|
/** Digits for the currencies these tests use; all real-world 2dp. */
|
|
const TEST_CURRENCIES = [
|
|
{ code: "DKK", symbol: "kr" },
|
|
{ code: "USD", symbol: "$" },
|
|
{ code: "EUR", symbol: "\u20ac" },
|
|
];
|
|
|
|
/** The dataset\'s decimal places; every currency rounds to this. */
|
|
const DIGITS = 2;
|
|
|
|
function post(id: string, order: number, percent: number, archived = false): Post {
|
|
return {
|
|
id,
|
|
name: id,
|
|
order,
|
|
folderId: null,
|
|
archived,
|
|
currency: "DKK",
|
|
rules: [{ from: "2026-01", rule: { 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, digits: DIGITS },
|
|
currencies: TEST_CURRENCIES,
|
|
fxRates: [],
|
|
folders: [],
|
|
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")],
|
|
recurring: [],
|
|
};
|
|
|
|
describe("monthView", () => {
|
|
test("counts a loose purchase as spent without charging a post", () => {
|
|
const loose: Dataset = { ...data, purchases: [{ ...spend("loose", "food", 75, "2026-01"), splits: [] }] };
|
|
const view = monthView(loose, "2026-01");
|
|
expect(view.totalCharges).toBe(75);
|
|
expect(view.rows.every((row) => row.figures.charges === 0)).toBe(true);
|
|
expect(view.rows[0]!.figures.remaining).toBe(2000);
|
|
});
|
|
|
|
test("counts financed loose slices in their months and converts currency", () => {
|
|
const loose: Dataset = {
|
|
...data,
|
|
fxRates: [{ currency: "EUR", baseUnitsPerOne: 7.5, updatedAt: "2026-01-01", source: "manual" }],
|
|
purchases: [{
|
|
...spend("loose", "food", 10, "2026-01"),
|
|
total: { amount: 10, currency: "EUR" },
|
|
splits: [],
|
|
schedule: { slices: [
|
|
{ month: "2026-01", amount: { amount: 4, currency: "EUR" } },
|
|
{ month: "2026-02", amount: { amount: 6, currency: "EUR" } },
|
|
] },
|
|
}],
|
|
};
|
|
expect(monthView(loose, "2026-01").totalCharges).toBe(30);
|
|
expect(monthView(loose, "2026-02").totalCharges).toBe(45);
|
|
expect(yearView(loose, 2026).totalCharges).toBe(75);
|
|
expect(summaryView(loose, "2026-01", "2026-02").looseCharges).toBe(75);
|
|
});
|
|
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("counts the posts that are overspent, so the month can say so at a glance", () => {
|
|
expect(monthView(data, "2026-01").overspentCount).toBe(0);
|
|
|
|
// food is allocated 2000 and spent 3000: one post behind.
|
|
const over: Dataset = {
|
|
...data,
|
|
purchases: [spend("a", "food", 3000, "2026-01-10")],
|
|
};
|
|
expect(monthView(over, "2026-01").overspentCount).toBe(1);
|
|
});
|
|
|
|
test("a post at exactly zero remaining is not overspent", () => {
|
|
// Rent-shaped: allocated 2000, spent 2000. Nothing left is not the same
|
|
// as owing something, and the header must not claim otherwise.
|
|
const exact: Dataset = {
|
|
...data,
|
|
purchases: [spend("a", "food", 2000, "2026-01-10")],
|
|
};
|
|
const view = monthView(exact, "2026-01");
|
|
expect(view.rows.find((r) => r.post.id === "food")!.figures.remaining).toBe(0);
|
|
expect(view.overspentCount).toBe(0);
|
|
});
|
|
|
|
test("no allocation rule stays distinct from an explicit zero allocation", () => {
|
|
const unbudgeted = { ...post("none", 0, 0), rules: [] };
|
|
const zero = post("zero", 1, 0);
|
|
const caseData: Dataset = {
|
|
...data,
|
|
posts: [unbudgeted, zero],
|
|
purchases: [spend("a", "none", 25, "2026-01"), spend("b", "zero", 25, "2026-01")],
|
|
};
|
|
const view = monthView(caseData, "2026-01");
|
|
expect(view.rows.map((row) => row.hasAllocationRule)).toEqual([false, true]);
|
|
expect(view.rows.map((row) => row.figures.remaining)).toEqual([-25, -25]);
|
|
expect(view.overspentCount).toBe(1);
|
|
expect(yearView(caseData, 2026).rows.map((row) => row.hasAllocationRuleByMonth[0])).toEqual([false, true]);
|
|
});
|
|
|
|
test("a monthly override counts as an allocation rule even before any standing rule", () => {
|
|
const caseData: Dataset = {
|
|
...data,
|
|
posts: [{ ...post("none", 0, 0), rules: [] }],
|
|
months: [{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {
|
|
none: { kind: "fixed", amount: { amount: 0, currency: "DKK" } },
|
|
} }],
|
|
purchases: [spend("a", "none", 25, "2026-01")],
|
|
};
|
|
const view = monthView(caseData, "2026-01");
|
|
expect(view.rows[0]!.hasAllocationRule).toBe(true);
|
|
expect(view.overspentCount).toBe(1);
|
|
});
|
|
|
|
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",
|
|
]);
|
|
});
|
|
|
|
test("an archived post with only an unconfirmed occurrence still appears, with its projected figure (I4)", () => {
|
|
// "old" has a zero-percent rule (no allocation) and no purchases at all,
|
|
// so carriedIn/allocation/charges are all zero this month — its only
|
|
// figure is the unconfirmed recurring occurrence's `expected`/`projected`.
|
|
// Before I4's fix, hasActivity ignored those two fields and dropped the
|
|
// row, even though ExpectedBand lists the same occurrence.
|
|
const withRecurring: Dataset = {
|
|
...data,
|
|
posts: [...data.posts, post("old", 2, 0, true)],
|
|
recurring: [
|
|
{
|
|
id: "r1",
|
|
name: "Storage unit",
|
|
order: 0,
|
|
folderId: null,
|
|
archived: false,
|
|
amount: { amount: 400, currency: "DKK" },
|
|
startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: "old", value: 100, absorbsRemainder: true }],
|
|
},
|
|
],
|
|
};
|
|
const view = monthView(withRecurring, "2026-01");
|
|
const row = view.rows.find((r) => r.post.id === "old");
|
|
expect(row).toBeDefined();
|
|
expect(row!.figures.carriedIn).toBe(0);
|
|
expect(row!.figures.allocation).toBe(0);
|
|
expect(row!.figures.charges).toBe(0);
|
|
expect(row!.figures.expected).toBe(400);
|
|
expect(row!.figures.projected).toBe(-400);
|
|
});
|
|
});
|
|
|
|
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("difference follows the dataset's decimal setting", () => {
|
|
const precise: Dataset = {
|
|
...data,
|
|
settings: { ...data.settings, digits: 3 },
|
|
months: [{ id: "2026-01", income: { amount: 1.234, currency: "DKK" }, ruleOverrides: {} }],
|
|
purchases: [spend("small", "food", 0.111, "2026-01-10")],
|
|
};
|
|
expect(summaryView(precise, "2026-01", "2026-01").difference).toBe(1.123);
|
|
});
|
|
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);
|
|
});
|
|
});
|
|
|
|
test("datasetMonthSpan covers foldStartMonth through the latest activity", () => {
|
|
expect(datasetMonthSpan(data)).toEqual({ from: "2026-01", to: "2026-02" });
|
|
});
|
|
|
|
test("datasetMonthSpan includes future finance-plan slices", () => {
|
|
const financed: Dataset = {
|
|
...data,
|
|
purchases: [
|
|
{
|
|
id: "f1",
|
|
date: "2026-01-05",
|
|
description: "Console",
|
|
total: { amount: 3000, currency: "DKK" },
|
|
splitMode: "percent",
|
|
splits: [{ postId: "games", value: 100, absorbsRemainder: true }],
|
|
schedule: {
|
|
slices: [
|
|
{ month: "2026-01", amount: { amount: 1500, currency: "DKK" } },
|
|
{ month: "2026-06", amount: { amount: 1500, currency: "DKK" } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
expect(datasetMonthSpan(financed)).toEqual({ from: "2026-01", to: "2026-06" });
|
|
});
|
|
|
|
test("datasetMonthSpan of an empty dataset is a single month", () => {
|
|
const bare: Dataset = { ...data, months: [], purchases: [] };
|
|
expect(datasetMonthSpan(bare)).toEqual({ from: "2026-01", to: "2026-01" });
|
|
});
|