feat(domain): add money arithmetic with exact distribution

Distribution computes the remainder part by subtraction so float splits
always sum exactly to the total. Omits money() and addAmounts() from the
brief since no downstream task consumes either and the brief's own tests
don't cover them.
This commit is contained in:
Lucas Winther committed 2026-09-01 16:25:40 +02:00
1 parent e1ac6d9fac
commit b2fa0e1b58
2 files changed
+129

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import { test, expect, describe } from "bun:test";
import {
roundMoney,
distributeByWeight,
distributeByAmount,
} from "./money.ts";
describe("roundMoney", () => {
test("rounds to 2 decimal places", () => {
expect(roundMoney(1.234)).toBe(1.23);
expect(roundMoney(1.235)).toBe(1.24);
expect(roundMoney(0.1 + 0.2)).toBe(0.3);
});
test("handles negatives symmetrically", () => {
expect(roundMoney(-1.235)).toBe(-1.24);
});
});
describe("distributeByWeight", () => {
test("splits proportionally", () => {
expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]);
});
test("thirds sum exactly to the total", () => {
const parts = distributeByWeight(100, [1, 1, 1], 2);
expect(parts).toEqual([33.33, 33.33, 33.34]);
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100);
});
test("the remainder index absorbs the rounding drift", () => {
const parts = distributeByWeight(100, [1, 1, 1], 0);
expect(parts).toEqual([33.34, 33.33, 33.33]);
});
test("a single split takes the whole total", () => {
expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]);
});
test("weights that do not sum to 100 are still proportional", () => {
expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]);
});
test("all-zero weights put everything on the remainder rather than throwing", () => {
// Reachable from a zero-value purchase; must not divide by zero.
expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]);
expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]);
});
});
describe("distributeByAmount", () => {
test("uses the given amounts as-is", () => {
expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]);
});
test("the remainder index absorbs a shortfall", () => {
expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]);
});
test("the remainder index absorbs an excess, even going negative", () => {
expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]);
});
test("parts always sum exactly to the total", () => {
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2);
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { CURRENCY_DIGITS, type Currency } from "./types.ts";
/**
* Rounds a float to its currency's minor unit. `toFixed` rounds the double's
* actual decimal value, which is the most predictable option available for
* binary floats. Call this after every division and FX conversion, and before
* persisting.
*/
export function roundMoney(amount: number, currency: Currency = "DKK"): number {
const digits = CURRENCY_DIGITS[currency];
return Number(amount.toFixed(digits));
}
function withRemainder(
total: number,
parts: number[],
remainderIndex: number,
currency: Currency,
): number[] {
const result = [...parts];
let othersSum = 0;
for (let i = 0; i < result.length; i++) {
if (i !== remainderIndex) othersSum += result[i]!;
}
// Never round this independently — subtraction is what makes the parts sum
// exactly to the total.
result[remainderIndex] = roundMoney(total - othersSum, currency);
return result;
}
/** Splits `total` proportionally to `weights`. Weights need not sum to 100. */
export function distributeByWeight(
total: number,
weights: number[],
remainderIndex: number,
currency: Currency = "DKK",
): number[] {
const weightSum = weights.reduce((a, b) => a + b, 0);
// All-zero weights would divide by zero. A zero-total purchase is legal (and
// reachable via import), so the whole amount goes to the remainder instead of
// throwing and taking the whole fold down with it.
const parts = weights.map((w, i) =>
i === remainderIndex || weightSum === 0
? 0
: roundMoney((total * w) / weightSum, currency),
);
return withRemainder(total, parts, remainderIndex, currency);
}
/** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */
export function distributeByAmount(
total: number,
amounts: number[],
remainderIndex: number,
currency: Currency = "DKK",
): number[] {
const parts = amounts.map((a, i) =>
i === remainderIndex ? 0 : roundMoney(a, currency),
);
return withRemainder(total, parts, remainderIndex, currency);
}