refactor(budget): the receipt split by line lives in shared so the server can split a receipt the way the cost editor does

calculateTicketShares in the cost editor now converts the hundredths of the
new shared splitTicketLines. Same arithmetic as before: a line nobody is
ticked for is carried by everyone on the receipt and the leftover hundredths
of a line go to the lowest user ids first.

Refs #2604
This commit is contained in:
Maurice
2026-10-12 16:39:07 +02:00
parent 92af437ff6
commit 2149fd5315
3 changed files with 70 additions and 34 deletions
+28 -1
View File
@@ -1,4 +1,4 @@
import { currencyDecimals, splitEqualShares, sumMinor, toMinor } from './money';
import { currencyDecimals, splitEqualShares, splitTicketLines, sumMinor, toMinor } from './money';
import { describe, expect, it } from 'vitest';
@@ -70,3 +70,30 @@ describe('splitEqualShares', () => {
expect(splitEqualShares(500, [], 3)).toEqual({});
});
});
describe('splitTicketLines', () => {
it('splits each line across the people ticked for it, leftover hundredths to the lowest ids', () => {
expect(
splitTicketLines([
{ price: '10', parts: [2, 1] },
{ price: '0.05', parts: [1, 2] },
{ price: 6, parts: [2] },
]),
).toEqual({ shares: { 1: 503, 2: 1102 }, totalMinor: 1605 });
});
it('puts a line nobody is ticked for on everyone on the receipt', () => {
const { shares, totalMinor } = splitTicketLines([
{ price: '30', parts: [1] },
{ price: '10', parts: [] },
{ price: '20', parts: [3] },
]);
expect(shares).toEqual({ 1: 3500, 3: 2500 });
expect(Object.values(shares).reduce((a, b) => a + b, 0)).toBe(totalMinor);
});
it('splits nothing when nobody is on the receipt', () => {
expect(splitTicketLines([{ price: '12', parts: [] }])).toEqual({ shares: {}, totalMinor: 1200 });
expect(splitTicketLines([])).toEqual({ shares: {}, totalMinor: 0 });
});
});
+38
View File
@@ -81,3 +81,41 @@ export function splitEqualShares(
});
return shares;
}
/** One line of an itemized receipt (#1658): its price as typed and who is in on it. */
export interface TicketLine {
price: string | number;
parts: readonly number[];
}
/**
* Per-person shares of a receipt split line by line, in whole hundredths keyed
* by user id, plus the receipt total.
*
* Every hundredth lands on somebody: a line nobody is ticked for is carried by
* everyone who appears anywhere on the receipt, so the shares add back up to
* the total (#1382). Within a line the leftover hundredths go to the lowest
* user ids first. A receipt with nobody on it at all splits nothing.
*
* The cost editor previews with this and the server re-splits a stored receipt
* with it when somebody leaves the trip, so the two never round apart.
*/
export function splitTicketLines(lines: readonly TicketLine[]): { shares: Record<number, number>; totalMinor: number } {
const shares: Record<number, number> = {};
let totalMinor = 0;
const everyone = [...new Set(lines.flatMap((line) => line.parts))].sort((a, b) => a - b);
for (const line of lines) {
const priceMinor = toMinor(Number.parseFloat(String(line.price)) || 0);
totalMinor += priceMinor;
const ids = line.parts.length > 0 ? [...new Set(line.parts)].sort((a, b) => a - b) : everyone;
const n = ids.length;
if (n === 0) continue;
const base = Math.floor(priceMinor / n);
const remainder = priceMinor - base * n;
ids.forEach((id, i) => {
shares[id] = (shares[id] ?? 0) + base + (i < remainder ? 1 : 0);
});
}
return { shares, totalMinor };
}