diff --git a/src/domain/money.test.ts b/src/domain/money.test.ts new file mode 100644 index 0000000..195f0b6 --- /dev/null +++ b/src/domain/money.test.ts @@ -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); + }); +}); diff --git a/src/domain/money.ts b/src/domain/money.ts new file mode 100644 index 0000000..fd806df --- /dev/null +++ b/src/domain/money.ts @@ -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); +}