diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 0a6ffbcae..c24d01bcb 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -11,7 +11,7 @@ * Amounts are the raw input strings, parsed on use (same as customAmounts). */ -import { splitEqualShares as splitEqualMinorShares, toMinor } from '@trek/shared' +import { splitEqualShares as splitEqualMinorShares, splitTicketLines, toMinor } from '@trek/shared' import type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared' import { amountToInputString, currencyDecimals } from '../../utils/formatters' import { localIsoDate } from '../../utils/localDate' @@ -267,39 +267,10 @@ export function readUserNote(item: { note?: string | null } | null | undefined): * anyway, since every line needs someone on it. */ export function calculateTicketShares(items: TicketItem[]): { shares: Record; total: number } { - const shares: Record = {} - let totalCents = 0 - - const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b) - - for (const item of items) { - const priceNum = Number.parseFloat(item.price) || 0 - const priceCents = Math.round(priceNum * 100) - totalCents += priceCents - - const sortedPartIds = item.participants.size > 0 - ? [...item.participants].sort((a, b) => a - b) - : everyone - const n = sortedPartIds.length - if (n === 0) continue - - const baseCents = Math.floor(priceCents / n) - const remainder = priceCents - baseCents * n - - for (let i = 0; i < n; i++) { - const id = sortedPartIds[i] - const hasExtraCent = i < remainder - const shareCents = baseCents + (hasExtraCent ? 1 : 0) - shares[id] = (shares[id] || 0) + shareCents - } - } - + const { shares, totalMinor } = splitTicketLines(items.map(i => ({ price: i.price, parts: [...i.participants] }))) const finalShares: Record = {} - for (const id of Object.keys(shares)) { - finalShares[Number(id)] = shares[Number(id)] / 100 - } - - return { shares: finalShares, total: totalCents / 100 } + for (const [id, cents] of Object.entries(shares)) finalShares[Number(id)] = cents / 100 + return { shares: finalShares, total: totalMinor / 100 } } /** diff --git a/shared/src/money/money.spec.ts b/shared/src/money/money.spec.ts index e72d12599..11594f41c 100644 --- a/shared/src/money/money.spec.ts +++ b/shared/src/money/money.spec.ts @@ -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 }); + }); +}); diff --git a/shared/src/money/money.ts b/shared/src/money/money.ts index 9f4e652d4..bb226b89f 100644 --- a/shared/src/money/money.ts +++ b/shared/src/money/money.ts @@ -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; totalMinor: number } { + const shares: Record = {}; + 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 }; +}