Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions src/store/addStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
);
}
Expand All @@ -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),
);
}
Expand Down
70 changes: 70 additions & 0 deletions src/tests/splitRoundTrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading