From 87d54fd9b7ecba0aa9909bc2021341f9815448af Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Tue, 25 Aug 2026 16:40:03 +0100 Subject: [PATCH] feat: add investment share percentage calculator and tests --- .../investment-share-calculator.utils.ts | 61 ++++++++++ .../investment-share-calculator.utils.test.ts | 104 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/utils/investment-share-calculator.utils.ts create mode 100644 tests/unit/utils/investment-share-calculator.utils.test.ts diff --git a/src/utils/investment-share-calculator.utils.ts b/src/utils/investment-share-calculator.utils.ts new file mode 100644 index 0000000..228afd2 --- /dev/null +++ b/src/utils/investment-share-calculator.utils.ts @@ -0,0 +1,61 @@ +export interface InvestmentShareInput { + id: string; + amount: bigint; +} + +export interface InvestmentShareResult { + id: string; + percentage: number; // exact integer percentage (e.g., 33 for 33%) +} + +/** + * Calculates investment shares using integer arithmetic to ensure the sum + * is exactly 100% and there is no floating point drift. + * Remainders are assigned to the investor with the largest amount. + */ +export function calculateInvestmentShares( + investments: InvestmentShareInput[] +): InvestmentShareResult[] { + if (investments.length === 0) { + return []; + } + + const totalAmount = investments.reduce((sum, inv) => sum + inv.amount, 0n); + + if (totalAmount === 0n) { + return investments.map((inv) => ({ + id: inv.id, + percentage: 0, + })); + } + + const results = investments.map((inv) => { + // Integer division to get the floor percentage + const percentage = Number((inv.amount * 100n) / totalAmount); + return { + id: inv.id, + percentage, + }; + }); + + const currentSum = results.reduce((sum, res) => sum + res.percentage, 0); + const remainder = 100 - currentSum; + + if (remainder > 0) { + // Find index of the largest amount. + // If there is a tie, the first one encountered (stable) will be chosen. + let maxAmount = -1n; + let maxIndex = 0; + + for (let i = 0; i < investments.length; i++) { + if (investments[i].amount > maxAmount) { + maxAmount = investments[i].amount; + maxIndex = i; + } + } + + results[maxIndex].percentage += remainder; + } + + return results; +} diff --git a/tests/unit/utils/investment-share-calculator.utils.test.ts b/tests/unit/utils/investment-share-calculator.utils.test.ts new file mode 100644 index 0000000..1e9e72f --- /dev/null +++ b/tests/unit/utils/investment-share-calculator.utils.test.ts @@ -0,0 +1,104 @@ +import { + calculateInvestmentShares, + InvestmentShareInput, +} from "../../../src/utils/investment-share-calculator.utils"; + +describe("Investment Share Calculator", () => { + it("should calculate exact percentages for a three-way split with remainder assigned to the first investor", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 3000n }, + { id: "inv2", amount: 3000n }, + { id: "inv3", amount: 3000n }, + ]; + + const results = calculateInvestmentShares(investments); + + expect(results).toHaveLength(3); + expect(results[0].percentage).toBe(34); + expect(results[1].percentage).toBe(33); + expect(results[2].percentage).toBe(33); + + const sum = results.reduce((acc, r) => acc + r.percentage, 0); + expect(sum).toBe(100); + }); + + it("should calculate exact percentages for a 25/75 split", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 2500n }, + { id: "inv2", amount: 7500n }, + ]; + + const results = calculateInvestmentShares(investments); + + expect(results).toHaveLength(2); + expect(results[0].percentage).toBe(25); + expect(results[1].percentage).toBe(75); + + const sum = results.reduce((acc, r) => acc + r.percentage, 0); + expect(sum).toBe(100); + }); + + it("should return 100% for a single investor", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 5000n }, + ]; + + const results = calculateInvestmentShares(investments); + + expect(results).toHaveLength(1); + expect(results[0].percentage).toBe(100); + + const sum = results.reduce((acc, r) => acc + r.percentage, 0); + expect(sum).toBe(100); + }); + + it("should assign the remainder to the investor with the largest commitment", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 3000n }, + { id: "inv2", amount: 3001n }, + { id: "inv3", amount: 3000n }, + ]; + + const results = calculateInvestmentShares(investments); + + expect(results).toHaveLength(3); + // Base is 33% for everyone, sum is 99%, remainder is 1% + // The largest is inv2 (3001). So it gets 34% + expect(results[0].percentage).toBe(33); + expect(results[1].percentage).toBe(34); + expect(results[2].percentage).toBe(33); + + const sum = results.reduce((acc, r) => acc + r.percentage, 0); + expect(sum).toBe(100); + }); + + it("should always sum to exactly 100%", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 1234n }, + { id: "inv2", amount: 5678n }, + { id: "inv3", amount: 9012n }, + { id: "inv4", amount: 3456n }, + ]; + + const results = calculateInvestmentShares(investments); + + const sum = results.reduce((acc, r) => acc + r.percentage, 0); + expect(sum).toBe(100); + }); + + it("should return 0 percentages if total amount is 0", () => { + const investments: InvestmentShareInput[] = [ + { id: "inv1", amount: 0n }, + { id: "inv2", amount: 0n }, + ]; + + const results = calculateInvestmentShares(investments); + expect(results[0].percentage).toBe(0); + expect(results[1].percentage).toBe(0); + }); + + it("should return empty array for empty input", () => { + const results = calculateInvestmentShares([]); + expect(results).toEqual([]); + }); +});