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:
1 parent
e1ac6d9fac
commit
b2fa0e1b58
2 files changed
+129
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user