From b9a8bfc3e7ab0442fd98b8f941c5bd845006bbce Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 21:43:08 +0200 Subject: [PATCH] refactor(budget): the cost preview splits through the shared cent split the settlement uses --- .../Budget/CostsPanel.helpers.test.ts | 8 ++-- .../components/Budget/CostsPanel.helpers.ts | 38 ++++--------------- shared/CLAUDE.md | 2 +- 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 6e1fc7586..1586c3739 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -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 }[] = [ { 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 } }, diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 678571546..4c15a587c 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -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 { - 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 = {} - 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. */ diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 6f7a7701f..cee2fdc84 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -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`)