mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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<number, number>; total: number } {
|
||||
const shares: Record<number, number> = {}
|
||||
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<number, number> = {}
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user