From 34d160ca138880f64ead0a233518236dbcb1cffa Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Fri, 16 Oct 2026 20:25:58 +1100 Subject: [PATCH] fix: reopen share and percentage splits with the simplest inputs (#727) Port of oss-apps/split-pro#774, adapted to the cloud's pick order: a 2:3 share split reopened as 1:1.5, and 43% of 7.03 reopened as 42.96%. Smallest whole shares (up to 100) and whole, then tenth percentages are now tried first, and still only used when they give back exactly the stored cents. --- src/store/addStore.ts | 38 ++++++++++------- src/tests/splitRoundTrip.test.ts | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 3c456f5..9798dea 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -844,7 +844,7 @@ export const getEditFormState = (expense: ExpenseToEdit) => { export const isCurrentForm = (generation: number) => generation === useAddExpenseStore.getState().formGeneration; -const gcd = (a: number, b: number): number => (0 === b ? a : gcd(b, a % b)); +const MAX_WHOLE_SHARE = 100; /** * Rebuilds the split inputs from participant amounts, e.g. when editing an expense or switching @@ -889,17 +889,24 @@ export function calculateSplitShareBasedOnAmount( return pick(owed.map(() => 0)); } - // Percentages in hundredths, with the rounding remainder on the largest share so they add - // up to exactly 100%. - const hundredths = owed.map((owedCents) => Math.round((owedCents / totalCents) * 10_000)); - const remainder = 10_000 - hundredths.reduce((total, value) => total + value, 0); - const largest = hundredths.indexOf(Math.max(...hundredths)); - if (-1 !== largest && Math.abs(remainder) <= participants.length) { - hundredths[largest] = (hundredths[largest] ?? 0) + remainder; - } + // Percentages with `decimals` decimals, with the rounding remainder on the largest share so + // they add up to exactly 100%. + const toPercentages = (decimals: number) => { + const scale = 10 ** decimals; + const units = owed.map((owedCents) => Math.round((owedCents / totalCents) * 100 * scale)); + const remainder = 100 * scale - units.reduce((total, value) => total + value, 0); + const largest = units.indexOf(Math.max(...units)); + if (-1 !== largest && Math.abs(remainder) <= participants.length) { + units[largest] = (units[largest] ?? 0) + remainder; + } + return units.map((value) => value / scale); + }; + // Whole percentages first, e.g. 43% rather than 42.96% when both give the same cents. return pick( - hundredths.map((value) => value / 100), + toPercentages(0), + toPercentages(1), + toPercentages(2), owed.map((owedCents) => (owedCents / totalCents) * 100), ); } @@ -912,13 +919,16 @@ export function calculateSplitShareBasedOnAmount( return pick(owed.map(() => 0)); } - // Whole numbers when the shares have a small common ratio, e.g. 30.00 and 70.00 give 3 and 7. - const divisor = owed.reduce((result, owedCents) => gcd(result, Math.abs(owedCents)), 0); - const wholeShares = owed.map((owedCents) => owedCents / divisor); + // The smallest whole numbers that give the same cents, e.g. 2 and 3 rather than 1 and 1.5, + // even when a leftover cent hides the exact ratio (31.17 and 46.76 of 77.93). Each candidate + // gives the smallest share one more, while every share stays at most 100. + const wholeShares = Array.from({ length: MAX_WHOLE_SHARE }, (_, index) => + owed.map((owedCents) => Math.round((owedCents * (index + 1)) / minOwed)), + ).filter((shares) => shares.every((share) => MAX_WHOLE_SHARE >= Math.abs(share))); return pick( + ...wholeShares, owed.map((owedCents) => Math.round((owedCents / minOwed) * 100) / 100), - ...(wholeShares.every((share) => 100 >= Math.abs(share)) ? [wholeShares] : []), owed.map((owedCents) => owedCents / minOwed), ); } diff --git a/src/tests/splitRoundTrip.test.ts b/src/tests/splitRoundTrip.test.ts index 0a8ee3e..fa3e7b2 100644 --- a/src/tests/splitRoundTrip.test.ts +++ b/src/tests/splitRoundTrip.test.ts @@ -161,6 +161,76 @@ void describe('split inputs rebuilt from amounts', () => { }); }); + void it('reopens a stored split with the smallest whole shares and whole percentages (#727)', () => { + [ + { amount: 77.93, splitType: SplitType.SHARE, entered: [2, 3], shown: [2, 3] }, + { amount: 100.01, splitType: SplitType.SHARE, entered: [2, 3], shown: [2, 3] }, + { amount: 100, splitType: SplitType.SHARE, entered: [4, 6], shown: [2, 3] }, + { amount: -77.93, splitType: SplitType.SHARE, entered: [3, 2], shown: [3, 2] }, + { amount: 120.02, splitType: SplitType.SHARE, entered: [2, 1, 1], shown: [2, 1, 1] }, + { amount: 77.93, splitType: SplitType.SHARE, entered: [0, 2, 3], shown: [0, 2, 3] }, + { amount: 77.93, splitType: SplitType.SHARE, entered: [1, 1.5], shown: [2, 3] }, + // No whole shares up to 100 give these cents, so the decimal share stays. + { amount: 779.3, splitType: SplitType.SHARE, entered: [1, 1.37], shown: [1, 1.37] }, + { amount: 7.03, splitType: SplitType.PERCENTAGE, entered: [43, 57], shown: [43, 57] }, + { + amount: 12.34, + splitType: SplitType.PERCENTAGE, + entered: [25, 50, 25], + shown: [25, 50, 25], + }, + { + amount: 77.93, + splitType: SplitType.PERCENTAGE, + entered: [37.5, 62.5], + shown: [37.5, 62.5], + }, + { + amount: 300.01, + splitType: SplitType.PERCENTAGE, + entered: [33.33, 33.33, 33.34], + shown: [33.33, 33.33, 33.34], + }, + ].forEach(({ amount, splitType, entered, shown }) => { + const users = [alice, bob, carol].slice(0, entered.length); + let { participants } = calculateParticipantSplit( + amount, + users.map((user, index) => ({ ...user, splitShare: entered[index] })), + splitType, + alice, + ); + const cents = toCents(participants); + + // Each save stores only the cents, so opening and saving again must not drift. + Array.from({ length: 3 }).forEach(() => { + const form = getEditFormState({ + amount: toInteger(amount), + splitType, + category: 'general', + currency: 'USD', + name: 'Reopened', + expenseDate: new Date(), + fileKey: null, + group: null, + paidByUser: alice, + expenseParticipants: participants.map((participant) => ({ + amount: toInteger(participant.amount ?? 0), + user: participant, + })), + }); + const label = `${amount} ${splitType} ${entered.join(':')}`; + + assert.deepEqual( + form.participants.map((participant) => participant.splitShare), + shown, + label, + ); + ({ participants } = calculateParticipantSplit(amount, form.participants, splitType, alice)); + assert.deepEqual(toCents(participants), cents, label); + }); + }); + }); + void it('converts 1,000.00 split equally three ways to percentages without losing cents', () => { [1000, -1000].forEach((amount) => { const equal = calculateParticipantSplit(