refactor(budget): the cost preview splits through the shared cent split the settlement uses

This commit is contained in:
Maurice
2026-10-10 21:43:08 +02:00
parent d2e044869a
commit b9a8bfc3e7
3 changed files with 12 additions and 36 deletions
@@ -104,11 +104,9 @@ describe('rebalancePayers', () => {
// ── Client/server share parity (#2176) ───────────────────────────────────────
//
// splitEqualShares exists twice: here (previewing the split in euros) and on the
// server (netting the settlement in cents — BudgetService.splitEqualShares in
// server/src/nest/budget/budget.service.ts). The fixture below is duplicated
// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either
// implementation drifts — sign handling included — its copy of this table fails.
// The preview's splitEqualShares returns euros on top of the shared cent split
// the server settles with (shared/src/money/money.ts, whose money.spec.ts holds
// the same table). This copy checks that the euro conversion keeps every cent.
const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record<number, number> }[] = [
{ totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } },
{ totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } },
@@ -11,6 +11,7 @@
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
import { splitEqualShares as splitEqualMinorShares, toMinor } from '@trek/shared'
import type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared'
import { amountToInputString, currencyDecimals } from '../../utils/formatters'
import type { ExpensePrefill } from './CostsPanel'
@@ -131,38 +132,15 @@ export function rebalancePayers(
}
/**
* Split `total` equally across `members`, in whole cents.
*
* The remainder cent rotates with the item id rather than always landing on the
* first member, so across several expenses the rounding evens out instead of
* always favouring the same person.
*
* Must stay share-for-share identical to the server's
* BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted
* there, this copy only previews it. The remainder is `totalCents - baseCents*n`
* rather than `%` so a negative total (a refund, #2176) still yields a remainder
* in [0, n) and the shares sum back to the total exactly, like the server's do.
* The parity fixture in CostsPanel.helpers.test.ts pins both sides.
* Split `total` equally across `members`, in whole cents, as euros for the
* preview. The cents come from the shared `splitEqualShares` the server nets
* the settlement with, so the preview and the settlement cannot disagree: the
* remainder cent rotates with the item id and a negative total (a refund,
* #2176) still sums back exactly.
*/
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents - baseCents * n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
const cents = splitEqualMinorShares(toMinor(total), members, itemId)
return Object.fromEntries(Object.entries(cents).map(([id, c]) => [id, c / 100]))
}
/** One line of a receipt: what it cost and who is in on it. */
+1 -1
View File
@@ -65,7 +65,7 @@ The parity rule governs **existing** routes; it is not a license to mint new deb
## Money (`src/money/money.ts`)
`toMinor`, `sumMinor`, `splitEqualShares` and `currencyDecimals` are the budget arithmetic both sides must agree on to the cent: amounts are netted in whole hundredths, equal splits hand the leftover hundredths out by rotation from `itemId % n`, and the shares always sum back to the total. The server's settlement uses these directly; `money.spec.ts` holds the share table that used to be duplicated between the server and client tests. The client still carries its own `splitEqualShares` (in euros, `client/src/components/Budget/CostsPanel.helpers.ts`) and `currencyDecimals` (`client/src/utils/formatters.ts`); folding them onto these is client work, not a second implementation to copy.
`toMinor`, `sumMinor`, `splitEqualShares` and `currencyDecimals` are the budget arithmetic both sides must agree on to the cent: amounts are netted in whole hundredths, equal splits hand the leftover hundredths out by rotation from `itemId % n`, and the shares always sum back to the total. The server's settlement uses these directly; `money.spec.ts` holds the share table that used to be duplicated between the server and client tests. The client's euro preview (`splitEqualShares` in `client/src/components/Budget/CostsPanel.helpers.ts`) converts the shared cent split; only its `currencyDecimals` (`client/src/utils/formatters.ts`) is still a copy of its own, and folding it onto this one is client work, not a second implementation to copy.
## Permissions (`src/permissions/permissions.ts`)