Files
budget2.0/src/domain/charges.test.ts
T
Lucas Winther 71d69c20b3 feat(domain): distribute purchase slices across posts
Slices are weighted by the whole-purchase split parts, so financed split
purchases need no special case and parts always sum to the slice total.
2026-09-01 19:40:32 +02:00

225 lines
7.4 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import {
splitPartsOfTotal,
sliceAmountForMonth,
chargesForPurchaseInMonth,
chargesForMonth,
} from "./charges.ts";
import { roundMoney } from "./money.ts";
import type { Dataset, FxRate, Purchase } from "./types.ts";
const rates: FxRate[] = [
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
];
function purchase(overrides: Partial<Purchase> = {}): Purchase {
return {
id: "x1",
date: "2026-09-14",
description: "Groceries",
total: { amount: 200, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: "food", value: 100, absorbsRemainder: true }],
schedule: null,
...overrides,
};
}
describe("splitPartsOfTotal", () => {
test("a single split takes the whole total", () => {
expect(splitPartsOfTotal(purchase())).toEqual([200]);
});
test("percent mode divides proportionally", () => {
const parts = splitPartsOfTotal(
purchase({
splits: [
{ postId: "food", value: 60, absorbsRemainder: true },
{ postId: "events", value: 40, absorbsRemainder: false },
],
}),
);
expect(parts).toEqual([120, 80]);
});
test("fixed mode uses the given amounts, remainder absorbing the shortfall", () => {
const parts = splitPartsOfTotal(
purchase({
splitMode: "fixed",
splits: [
{ postId: "food", value: 120, absorbsRemainder: false },
{ postId: "events", value: 50, absorbsRemainder: true },
],
}),
);
expect(parts).toEqual([120, 80]);
});
test("three-way percent splits sum exactly to the total", () => {
const parts = splitPartsOfTotal(
purchase({
total: { amount: 100, currency: "DKK" },
splits: [
{ postId: "a", value: 33.33, absorbsRemainder: false },
{ postId: "b", value: 33.33, absorbsRemainder: false },
{ postId: "c", value: 33.34, absorbsRemainder: true },
],
}),
);
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100);
});
});
describe("sliceAmountForMonth", () => {
test("an unscheduled purchase charges its whole total in the month of its date", () => {
expect(sliceAmountForMonth(purchase(), "2026-09")).toEqual({
amount: 200,
currency: "DKK",
});
});
test("an unscheduled purchase charges nothing in other months", () => {
expect(sliceAmountForMonth(purchase(), "2026-10")).toBeNull();
});
test("a scheduled purchase charges only its slice for that month", () => {
const financed = purchase({
total: { amount: 3000, currency: "DKK" },
schedule: {
slices: [
{ month: "2026-10", amount: { amount: 1000, currency: "DKK" } },
{ month: "2026-11", amount: { amount: 500, currency: "DKK" } },
{ month: "2026-12", amount: { amount: 1500, currency: "DKK" } },
],
},
});
expect(sliceAmountForMonth(financed, "2026-11")).toEqual({
amount: 500,
currency: "DKK",
});
expect(sliceAmountForMonth(financed, "2026-09")).toBeNull();
});
test("cancellation drops the cancelled month and every later one", () => {
const cancelled = purchase({
total: { amount: 3000, currency: "DKK" },
schedule: {
slices: [
{ month: "2026-10", amount: { amount: 1000, currency: "DKK" } },
{ month: "2026-11", amount: { amount: 1000, currency: "DKK" } },
{ month: "2026-12", amount: { amount: 1000, currency: "DKK" } },
],
cancelledFromMonth: "2026-11",
},
});
expect(sliceAmountForMonth(cancelled, "2026-10")).not.toBeNull();
expect(sliceAmountForMonth(cancelled, "2026-11")).toBeNull();
expect(sliceAmountForMonth(cancelled, "2026-12")).toBeNull();
});
});
describe("chargesForPurchaseInMonth", () => {
test("a simple purchase charges one post", () => {
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([
{ postId: "food", amount: 200 },
]);
});
test("converts a foreign-currency purchase into base", () => {
const eur = purchase({ total: { amount: 10, currency: "EUR" } });
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([
{ postId: "food", amount: 74.6 },
]);
});
test("a financed split purchase divides each slice by the split ratio", () => {
const financedSplit = purchase({
total: { amount: 3000, currency: "DKK" },
splits: [
{ postId: "games", value: 70, absorbsRemainder: true },
{ postId: "events", value: 30, absorbsRemainder: false },
],
schedule: {
slices: [
{ month: "2026-10", amount: { amount: 1000, currency: "DKK" } },
{ month: "2026-11", amount: { amount: 2000, currency: "DKK" } },
],
},
});
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([
{ postId: "games", amount: 700 },
{ postId: "events", amount: 300 },
]);
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([
{ postId: "games", amount: 1400 },
{ postId: "events", amount: 600 },
]);
});
test("a financed fixed-mode split scales the fixed amounts down per slice", () => {
const financed = purchase({
total: { amount: 3000, currency: "DKK" },
splitMode: "fixed",
splits: [
{ postId: "games", value: 2000, absorbsRemainder: true },
{ postId: "events", value: 1000, absorbsRemainder: false },
],
schedule: {
slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }],
},
});
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([
{ postId: "games", amount: 400 },
{ postId: "events", amount: 200 },
]);
});
test("charges in a month the purchase does not touch are empty", () => {
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]);
});
test("slice charges always sum to the slice total", () => {
const odd = purchase({
total: { amount: 100, currency: "DKK" },
splits: [
{ postId: "a", value: 1, absorbsRemainder: false },
{ postId: "b", value: 1, absorbsRemainder: false },
{ postId: "c", value: 1, absorbsRemainder: true },
],
});
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates);
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100);
});
});
describe("chargesForMonth", () => {
test("sums charges per post across all purchases", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
fxRates: rates,
posts: [],
months: [],
purchases: [
purchase({ id: "a" }),
purchase({ id: "b", total: { amount: 50, currency: "DKK" } }),
purchase({ id: "c", date: "2026-10-01" }),
],
};
const charges = chargesForMonth(data, "2026-09");
expect(charges.get("food")).toBe(250);
expect(charges.size).toBe(1);
});
test("a month with no purchases yields an empty map", () => {
const data: Dataset = {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
fxRates: rates,
posts: [],
months: [],
purchases: [],
};
expect(chargesForMonth(data, "2026-09").size).toBe(0);
});
});