From 5e3cc7a0e1d9be972e8bdd486272b7cab8a672a7 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 20:31:48 +0200 Subject: [PATCH] fix(budget): add back the multi payer selection --- .../Budget/CostsPanel.helpers.test.ts | 78 ++++++++++ .../components/Budget/CostsPanel.helpers.ts | 53 +++++++ .../src/components/Budget/CostsPanel.test.tsx | 112 ++++++++++++++ client/src/components/Budget/CostsPanel.tsx | 145 ++++++++++++++++-- .../tests/unit/services/budgetService.test.ts | 72 +++++++++ shared/src/i18n/ar/budget.ts | 3 + shared/src/i18n/br/budget.ts | 3 + shared/src/i18n/cs/budget.ts | 3 + shared/src/i18n/de/budget.ts | 3 + shared/src/i18n/en/budget.ts | 3 + shared/src/i18n/es/budget.ts | 3 + shared/src/i18n/fr/budget.ts | 3 + shared/src/i18n/gr/budget.ts | 3 + shared/src/i18n/hu/budget.ts | 3 + shared/src/i18n/id/budget.ts | 3 + shared/src/i18n/it/budget.ts | 3 + shared/src/i18n/ja/budget.ts | 3 + shared/src/i18n/ko/budget.ts | 3 + shared/src/i18n/nl/budget.ts | 3 + shared/src/i18n/pl/budget.ts | 3 + shared/src/i18n/ru/budget.ts | 3 + shared/src/i18n/sv/budget.ts | 3 + shared/src/i18n/tr/budget.ts | 3 + shared/src/i18n/uk/budget.ts | 3 + shared/src/i18n/vi/budget.ts | 3 + shared/src/i18n/zh-TW/budget.ts | 3 + shared/src/i18n/zh/budget.ts | 3 + 27 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 client/src/components/Budget/CostsPanel.helpers.test.ts create mode 100644 client/src/components/Budget/CostsPanel.helpers.ts diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts new file mode 100644 index 000000000..80ac00812 --- /dev/null +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers' + +describe('splitCents', () => { + it('splits evenly when it divides cleanly', () => { + expect(splitCents(90, 3)).toEqual([30, 30, 30]) + }) + + it('distributes the remainder cents so the parts sum back exactly', () => { + const parts = splitCents(100.01, 3) + expect(parts).toEqual([33.34, 33.34, 33.33]) + expect(parts.reduce((a, b) => a + b, 0)).toBeCloseTo(100.01, 2) + }) + + it('returns an empty list for a non-positive count', () => { + expect(splitCents(50, 0)).toEqual([]) + }) + + it('floors a negative amount at zero rather than inventing debt', () => { + expect(splitCents(-10, 2)).toEqual([0, 0]) + }) +}) + +describe('payerSum', () => { + it('sums only the selected payers', () => { + const amounts = { 1: '45', 2: '45', 3: '99' } + expect(payerSum(amounts, new Set([1, 2]))).toBeCloseTo(90, 2) + }) + + it('treats blank and unparseable amounts as zero', () => { + expect(payerSum({ 1: '', 2: 'abc' }, new Set([1, 2]))).toBe(0) + }) +}) + +describe('payersBalanced', () => { + it('is true when the payer amounts add up to the total', () => { + expect(payersBalanced({ 1: '45', 2: '45' }, new Set([1, 2]), 90)).toBe(true) + }) + + it('is false when they do not', () => { + expect(payersBalanced({ 1: '45', 2: '40' }, new Set([1, 2]), 90)).toBe(false) + }) + + it('compares to the cent, tolerating float dust', () => { + expect(payersBalanced({ 1: '33.34', 2: '33.34', 3: '33.33' }, new Set([1, 2, 3]), 100.01)).toBe(true) + }) +}) + +describe('rebalancePayers', () => { + it('spreads the total across payers when none are pinned', () => { + const next = rebalancePayers({}, new Set(), new Set([1, 2]), 90) + expect(next).toEqual({ 1: '45.00', 2: '45.00' }) + }) + + it('leaves pinned payers alone and lets the rest absorb the remainder', () => { + // Alice pinned at 70 of a 100 bill → Bob must absorb 30. + const next = rebalancePayers({ 1: '70' }, new Set([1]), new Set([1, 2]), 100) + expect(next[1]).toBe('70') + expect(next[2]).toBe('30.00') + }) + + it('returns the amounts untouched when every payer is pinned', () => { + const amounts = { 1: '70', 2: '20' } + const next = rebalancePayers(amounts, new Set([1, 2]), new Set([1, 2]), 100) + expect(next).toEqual(amounts) + }) + + it('blanks a free payer whose share works out to zero', () => { + // Alice pinned at the full total → Bob is a payer with nothing left to pay. + const next = rebalancePayers({ 1: '100' }, new Set([1]), new Set([1, 2]), 100) + expect(next[2]).toBe('') + }) + + it('keeps the result balanced after rebalancing', () => { + const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100) + expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts new file mode 100644 index 000000000..ae297b9f8 --- /dev/null +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -0,0 +1,53 @@ +/** + * Pure payer math for the Costs expense modal. + * + * An expense's payers must always sum to its total. The server re-derives + * budget_items.total_price from the payer sum (budgetService.createItem), so an + * unbalanced payer list would silently rewrite the expense total — and in custom + * split mode the member debits, balanced against the old total, would stop + * cancelling the payer credits. rebalancePayers keeps the payers the user hasn't + * touched absorbing the remainder as they type; payersBalanced gates the save. + * + * Amounts are the raw input strings, parsed on use (same as customAmounts). + */ + +/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */ +export function splitCents(amount: number, n: number): number[] { + if (n <= 0) return [] + const cents = Math.max(0, Math.round(amount * 100)) + const base = Math.floor(cents / n) + const rem = cents - base * n + return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100) +} + +/** Sum the amounts of the selected payers. */ +export function payerSum(amounts: Record, ids: Set): number { + return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0) +} + +/** True when the payer amounts add up to the expense total, to the cent. */ +export function payersBalanced(amounts: Record, ids: Set, total: number): boolean { + return Math.round(payerSum(amounts, ids) * 100) === Math.round(total * 100) +} + +/** + * Recompute the payers the user has not explicitly edited (everyone not in + * `pinned`) so the whole list sums to `total`. Pinned amounts are left as typed. + */ +export function rebalancePayers( + amounts: Record, + pinned: Set, + ids: Set, + total: number, +): Record { + const all = [...ids] + const free = all.filter(id => !pinned.has(id)) + if (free.length === 0) return amounts + const pinnedSum = all + .filter(id => pinned.has(id)) + .reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0) + const shares = splitCents(total - pinnedSum, free.length) + const next = { ...amounts } + free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' }) + return next +} diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 5ddf8da9a..d5ec949b1 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -270,6 +270,118 @@ describe('CostsPanel — settlements in the ledger', () => { expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument() }) + // ── Multi-payer (#1426 regression) ───────────────────────────────────────── + // 3.2.0 collapsed payers[] to a single payer, so a bill fronted by two people + // credited all of it to one and skewed settle-up. The ledger always supported N + // payers; only the form could no longer send them. + + it('records an expense paid by two people with their own amounts', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) + let posted: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 11 } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') + await user.type(screen.getAllByPlaceholderText('0.00')[0], '90') + + await user.click(screen.getByRole('button', { name: 'Multiple people paid' })) + + // Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45. + await user.click(screen.getAllByTestId('payer-toggle')[1]) + expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value)) + .toEqual(['45.00', '45.00']) + + const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) + await user.click(addBtns[addBtns.length - 1]) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.total_price).toBe(90) + expect(posted!.payers).toEqual(expect.arrayContaining([ + { user_id: 1, amount: 45 }, + { user_id: 2, amount: 45 }, + ])) + expect(posted!.payers).toHaveLength(2) + }) + + it('blocks saving when the payer amounts do not add up to the total', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) + let posted: Record | null = null + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: 'Dinner' }) }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner') + await user.type(screen.getAllByPlaceholderText('0.00')[0], '90') + await user.click(screen.getByRole('button', { name: 'Multiple people paid' })) + await user.click(screen.getAllByTestId('payer-toggle')[1]) + + // Pin both payers at 20 of a 90 bill, so nobody is left to absorb the rest. + const amounts = () => screen.getAllByTestId('payer-amount') as HTMLInputElement[] + await user.clear(amounts()[0]) + await user.type(amounts()[0], '20') + await user.clear(amounts()[1]) + await user.type(amounts()[1], '20') + + // An unbalanced payer list would make the server re-derive total_price as 40. + expect(screen.getByText(/must add up to/i)).toBeInTheDocument() + const addBtns = screen.getAllByRole('button', { name: 'Add expense' }) + expect(addBtns[addBtns.length - 1]).toBeDisabled() + expect(posted).toBeNull() + }) + + it('reopens a two-payer expense with both payers intact', async () => { + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true }) + let put: Record | null = null + const item = { + ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), + id: 7, + total_price: 90, + payers: [{ user_id: 1, amount: 45, username: 'alice' }, { user_id: 2, amount: 45, username: 'bob' }], + members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }], + } + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })), + http.put('/api/trips/1/budget/7', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ item }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await screen.findByText('Dinner') + await user.click(screen.getByTitle('Edit')) + + // Loading used to be payers.find(...), which silently dropped the second payer. + const amounts = await screen.findAllByTestId('payer-amount') + expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45']) + + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(put).toBeTruthy()) + expect(put!.payers).toHaveLength(2) + }) + it('exports the expenses as a CSV download (#1500)', async () => { // Display in the trip's own currency so FX conversion is an identity. seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 4b83c8f1d..98e19fe37 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -15,6 +15,7 @@ import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' +import { payersBalanced, rebalancePayers } from './CostsPanel.helpers' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' @@ -989,13 +990,29 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [participants, setParticipants] = useState>(() => editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) - // Payer state: 0 represents "Nobody (planning entry)". On an existing expense a - // missing payer is a deliberate choice, so only a brand-new one defaults to me. + // Payer state. An expense can be fronted by several people, each with their own + // amount (budget_item_payers) — a shared card, or "I got this round, you get the + // next". The single-payer dropdown stays the default path; multiPayer swaps in a + // per-person amount editor. 0 represents "Nobody (planning entry)"; on an + // existing expense a missing payer is a deliberate choice, so only a brand-new + // one defaults to me. + const initialPayers = (editing?.payers || []).filter(p => p.amount > 0) + const [payerId, setPayerId] = useState(() => { - const existingPayer = (editing?.payers || []).find(p => p.amount > 0) + const existingPayer = initialPayers[0] if (existingPayer) return existingPayer.user_id return editing ? 0 : me }) + const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1) + const [payerIds, setPayerIds] = useState>(() => new Set(initialPayers.map(p => p.user_id))) + const [payerAmounts, setPayerAmounts] = useState>(() => { + const m: Record = {} + for (const p of initialPayers) m[p.user_id] = String(p.amount) + return m + }) + // Payers the user typed an amount for: rebalance leaves these alone and makes + // the others absorb the remainder. + const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => { if (editing?.note && editing.note.startsWith('TICKETJSON:')) { @@ -1066,7 +1083,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }, [totalNum, participants, customAmounts, editing]) const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0) - const valid = name.trim().length > 0 && ( + const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) + const valid = name.trim().length > 0 && payersOk && ( isTicketMode ? ticketValid : totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced) @@ -1076,6 +1094,52 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo setTotal(v.replace(',', '.')) } + // Keep the payer amounts summing to the total as it changes — including in ticket + // mode, where the total is derived from the ticket items rather than typed. + useEffect(() => { + if (!multiPayer) return + setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [totalNum]) + + const enableMultiPayer = () => { + const seed = payerIds.size > 0 ? new Set(payerIds) : new Set([payerId > 0 ? payerId : me]) + const pinned = new Set() + setPayerIds(seed) + setPinnedPayers(pinned) + setPayerAmounts(prev => rebalancePayers(prev, pinned, seed, totalNum)) + setMultiPayer(true) + } + + const disableMultiPayer = () => { + // Collapsing back keeps the first payer; their amount becomes the whole total. + const [first] = [...payerIds] + setPayerId(first ?? me) + setMultiPayer(false) + } + + const togglePayer = (id: number) => { + const nextIds = new Set(payerIds) + const nextPinned = new Set(pinnedPayers) + if (nextIds.has(id)) { + nextIds.delete(id) + nextPinned.delete(id) + } else { + nextIds.add(id) + } + setPayerIds(nextIds) + setPinnedPayers(nextPinned) + setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum)) + } + + const onPayerAmountChange = (id: number, v: string) => { + const val = v.replace(',', '.') + const nextPinned = new Set(pinnedPayers) + nextPinned.add(id) + setPinnedPayers(nextPinned) + setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum)) + } + const handleCustomAmountChange = (id: number, val: string) => { val = val.replace(',', '.') if (/^\d*\.?\d{0,2}$/.test(val) || val === '') { @@ -1140,7 +1204,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const save = async () => { if (!valid) return setSaving(true) - const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : [] + const payerList = multiPayer + ? [...payerIds] + .map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 })) + .filter(p => p.amount > 0) + : (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : [] const memberList = [...participants].map(id => ({ user_id: id, amount: splitMode === 'custom' @@ -1245,13 +1313,66 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- - setPayerId(Number(v))} - options={[ - { value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' }, - ...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) - ]} - style={{ width: '100%' }} /> +
+ + +
+ {!multiPayer ? ( + setPayerId(Number(v))} + options={[ + { value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' }, + ...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) + ]} + style={{ width: '100%' }} /> + ) : ( + <> +
+ {people.map((p, idx) => { + const on = payerIds.has(p.id) + return ( +
+ + {on ? ( +
+ {sym(currency)} + onPayerAmountChange(p.id, v)} + className="text-content" + style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} /> +
+ ) : ( + + )} +
+ ) + })} +
+ {!payersOk && ( +
+ {t('costs.payersUnbalanced', { amount: formatMoney(totalNum, currency, locale) })} +
+ )} + + )}
diff --git a/server/tests/unit/services/budgetService.test.ts b/server/tests/unit/services/budgetService.test.ts index 7e29b8591..47e654c6a 100644 --- a/server/tests/unit/services/budgetService.test.ts +++ b/server/tests/unit/services/budgetService.test.ts @@ -287,6 +287,78 @@ describe('calculateSettlement', () => { // settleToTrip(62.5) = 62.5 * 0.5 = 31.25 EUR; balance -50 + 31.25 = -18.75 EUR → reopens. expect(Math.abs(bob.balance)).toBeGreaterThan(1); }); + + // ── Multi-payer (#1426 regression): several people front one bill ────────── + // The UI could only send one payer between 3.2.0 and this fix, but the ledger + // has credited each payer individually since 3.1.0. These pin that. + + it('2 payers, 3 members: each payer is credited what they actually paid', () => { + // $90 bill split 3 ways ($30 each). Alice and Bob each fronted $45. + // Alice: +45 - 30 = +15. Bob: +45 - 30 = +15. Carol: -30. + setupDb( + [makeItem(1, 90)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob')], + ); + const result = calculateSettlement(1); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + expect(balance(1)).toBeCloseTo(15, 2); + expect(balance(2)).toBeCloseTo(15, 2); + expect(balance(3)).toBeCloseTo(-30, 2); + }); + + it('2 payers with unequal amounts: credits follow the actual amounts paid', () => { + // $100 bill split 2 ways ($50 each). Alice fronted $70, Bob fronted $30. + // Alice: +70 - 50 = +20. Bob: +30 - 50 = -20. Bob owes Alice $20. + setupDb( + [makeItem(1, 100)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob')], + [makePayer(1, 1, 70, 'alice'), makePayer(1, 2, 30, 'bob')], + ); + const result = calculateSettlement(1); + const balance = (uid: number) => result.balances.find(b => b.user_id === uid)!.balance; + + expect(balance(1)).toBeCloseTo(20, 2); + expect(balance(2)).toBeCloseTo(-20, 2); + expect(result.flows).toHaveLength(1); + expect(result.flows[0].from.user_id).toBe(2); + expect(result.flows[0].to.user_id).toBe(1); + expect(result.flows[0].amount).toBeCloseTo(20, 2); + }); + + it('multi-payer balances sum to zero (no money invented or destroyed)', () => { + // The invariant that makes settle-up trustworthy: credits == debits. + setupDb( + [makeItem(1, 90), makeItem(2, 55)], + [ + makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol'), + makeMember(2, 1, 'alice'), makeMember(2, 3, 'carol'), + ], + [ + makePayer(1, 1, 45, 'alice'), makePayer(1, 2, 45, 'bob'), + makePayer(2, 2, 25, 'bob'), makePayer(2, 3, 30, 'carol'), + ], + ); + const result = calculateSettlement(1); + const sum = result.balances.reduce((a, b) => a + b.balance, 0); + + expect(sum).toBeCloseTo(0, 2); + }); + + it('3 payers on one bill: an odd total still splits to the cent', () => { + // $100.01 split 3 ways. splitEqualShares distributes the remainder cent, + // so debits must still exactly cancel the $100.01 of credits. + setupDb( + [makeItem(1, 100.01)], + [makeMember(1, 1, 'alice'), makeMember(1, 2, 'bob'), makeMember(1, 3, 'carol')], + [makePayer(1, 1, 33.34, 'alice'), makePayer(1, 2, 33.34, 'bob'), makePayer(1, 3, 33.33, 'carol')], + ); + const result = calculateSettlement(1); + const sum = result.balances.reduce((a, b) => a + b.balance, 0); + + expect(sum).toBeCloseTo(0, 2); + }); }); // ── freezeForeignRate (write-path FX freeze, #1445) ─────────────────────────── diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index be9e961c4..becb1d147 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} بـ {to}', 'costs.category': 'الفئة', 'costs.whoPaid': 'من دفع؟', + 'costs.multiplePayers': 'دفع عدة أشخاص', + 'costs.singlePayer': 'دفع شخص واحد', + 'costs.payersUnbalanced': 'يجب أن يكون مجموع مبالغ الدافعين {amount}', 'costs.splitBetween': 'تقسيم بالتساوي بين', 'costs.pickSomeone': 'اختر شخصًا واحدًا على الأقل للتقسيم معه.', 'costs.splitSummary': 'تقسيم على {count} · {amount} لكل واحد', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 828bab14c..e2ef3c432 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} em {to}', 'costs.category': 'Categoria', 'costs.whoPaid': 'Quem pagou?', + 'costs.multiplePayers': 'Várias pessoas pagaram', + 'costs.singlePayer': 'Uma pessoa pagou', + 'costs.payersUnbalanced': 'Os valores dos pagadores devem somar {amount}', 'costs.splitBetween': 'Dividir igualmente entre', 'costs.pickSomeone': 'Escolha pelo menos uma pessoa para dividir.', 'costs.splitSummary': 'Dividido entre {count} · {amount} cada', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 2a6d9244b..109e0cb53 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} v {to}', 'costs.category': 'Kategorie', 'costs.whoPaid': 'Kdo zaplatil?', + 'costs.multiplePayers': 'Platilo více lidí', + 'costs.singlePayer': 'Platil jeden člověk', + 'costs.payersUnbalanced': 'Součet částek plátců musí být {amount}', 'costs.splitBetween': 'Rozdělit rovným dílem mezi', 'costs.pickSomeone': 'Vyberte alespoň jednu osobu pro rozdělení.', 'costs.splitSummary': 'Rozděleno na {count} dílů · {amount} každý', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index a314a0ed7..2265bf3ad 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -96,6 +96,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} in {to}', 'costs.category': 'Kategorie', 'costs.whoPaid': 'Wer hat bezahlt?', + 'costs.multiplePayers': 'Mehrere haben bezahlt', + 'costs.singlePayer': 'Eine Person hat bezahlt', + 'costs.payersUnbalanced': 'Die Beträge der Zahlenden müssen zusammen {amount} ergeben', 'costs.splitBetween': 'Gleichmäßig teilen zwischen', 'costs.pickSomeone': 'Wähle mindestens eine Person zum Teilen.', 'costs.splitSummary': 'Auf {count} aufgeteilt · {amount} pro Person', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index 05b6e01f6..c744ce142 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -96,6 +96,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} in {to}', 'costs.category': 'Category', 'costs.whoPaid': 'Who paid?', + 'costs.multiplePayers': 'Multiple people paid', + 'costs.singlePayer': 'One person paid', + 'costs.payersUnbalanced': 'Payer amounts must add up to {amount}', 'costs.splitBetween': 'Split equally between', 'costs.split': 'Split', 'costs.splitEqually': 'Equally', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 66e78f2dd..cc07aff65 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} en {to}', 'costs.category': 'Categoría', 'costs.whoPaid': '¿Quién pagó?', + 'costs.multiplePayers': 'Pagaron varias personas', + 'costs.singlePayer': 'Pagó una sola persona', + 'costs.payersUnbalanced': 'Los importes de los pagadores deben sumar {amount}', 'costs.splitBetween': 'Dividir a partes iguales entre', 'costs.pickSomeone': 'Elige al menos una persona con quien dividir.', 'costs.splitSummary': 'Dividido entre {count} · {amount} cada uno', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index ba0c76d50..319c5bfeb 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} en {to}', 'costs.category': 'Catégorie', 'costs.whoPaid': 'Qui a payé ?', + 'costs.multiplePayers': 'Plusieurs personnes ont payé', + 'costs.singlePayer': 'Une seule personne a payé', + 'costs.payersUnbalanced': 'Le total des payeurs doit être égal à {amount}', 'costs.splitBetween': 'Partager équitablement entre', 'costs.pickSomeone': 'Choisissez au moins une personne avec qui partager.', 'costs.splitSummary': 'Partagé en {count} · {amount} chacun', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index 9f759af3c..a14ebf20c 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -94,6 +94,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} σε {to}', 'costs.category': 'Κατηγορία', 'costs.whoPaid': 'Ποιος πλήρωσε;', + 'costs.multiplePayers': 'Πλήρωσαν πολλά άτομα', + 'costs.singlePayer': 'Πλήρωσε ένα άτομο', + 'costs.payersUnbalanced': 'Τα ποσά των πληρωτών πρέπει να αθροίζουν σε {amount}', 'costs.splitBetween': 'Ισόποση κατανομή μεταξύ', 'costs.pickSomeone': 'Επιλέξτε τουλάχιστον ένα άτομο για τον διαμοιρασμό.', 'costs.splitSummary': 'Κατανομή σε {count} μέρη · {amount} το καθένα', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index dbcc600b4..98f210e79 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} ennyi: {to}', 'costs.category': 'Kategória', 'costs.whoPaid': 'Ki fizetett?', + 'costs.multiplePayers': 'Többen fizettek', + 'costs.singlePayer': 'Egy személy fizetett', + 'costs.payersUnbalanced': 'A fizetők összegeinek {amount} értéket kell kiadniuk', 'costs.splitBetween': 'Egyenlően elosztva köztük', 'costs.pickSomeone': 'Válassz legalább egy személyt a megosztáshoz.', 'costs.splitSummary': '{count} fő közt megosztva · egyenként {amount}', diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index 31f688311..ee081eebf 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} dalam {to}', 'costs.category': 'Kategori', 'costs.whoPaid': 'Siapa yang membayar?', + 'costs.multiplePayers': 'Beberapa orang membayar', + 'costs.singlePayer': 'Satu orang membayar', + 'costs.payersUnbalanced': 'Jumlah pembayar harus berjumlah {amount}', 'costs.splitBetween': 'Bagi rata antara', 'costs.pickSomeone': 'Pilih setidaknya satu orang untuk berbagi.', 'costs.splitSummary': 'Dibagi {count} cara · {amount} masing-masing', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index 8651090be..5358f91c6 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} in {to}', 'costs.category': 'Categoria', 'costs.whoPaid': 'Chi ha pagato?', + 'costs.multiplePayers': 'Hanno pagato più persone', + 'costs.singlePayer': 'Ha pagato una sola persona', + 'costs.payersUnbalanced': 'Gli importi dei paganti devono sommare a {amount}', 'costs.splitBetween': 'Dividi equamente tra', 'costs.pickSomeone': 'Scegli almeno una persona con cui dividere.', 'costs.splitSummary': 'Diviso in {count} · {amount} ciascuno', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index 136e11559..dad7058a7 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} = {to}', 'costs.category': 'カテゴリ', 'costs.whoPaid': '誰が支払いましたか?', + 'costs.multiplePayers': '複数人が支払い', + 'costs.singlePayer': '1人が支払い', + 'costs.payersUnbalanced': '支払者の金額の合計は {amount} である必要があります', 'costs.splitBetween': '均等に分割する相手', 'costs.pickSomeone': '分割する相手を少なくとも1人選んでください。', 'costs.splitSummary': '{count}人で分割 · 各{amount}', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 93e93d00a..4767dd3d8 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} = {to}', 'costs.category': '카테고리', 'costs.whoPaid': '누가 지불했나요?', + 'costs.multiplePayers': '여러 명이 결제함', + 'costs.singlePayer': '한 명이 결제함', + 'costs.payersUnbalanced': '결제자 금액의 합계가 {amount} 여야 합니다', 'costs.splitBetween': '균등 분할 대상', 'costs.pickSomeone': '분할할 사람을 한 명 이상 선택하세요.', 'costs.splitSummary': '{count}명 분할 · 각 {amount}', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index 811c84199..34caf4eb7 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} in {to}', 'costs.category': 'Categorie', 'costs.whoPaid': 'Wie heeft betaald?', + 'costs.multiplePayers': 'Meerdere personen hebben betaald', + 'costs.singlePayer': 'Eén persoon heeft betaald', + 'costs.payersUnbalanced': 'De bedragen van de betalers moeten optellen tot {amount}', 'costs.splitBetween': 'Gelijk verdelen over', 'costs.pickSomeone': 'Kies minstens één persoon om mee te delen.', 'costs.splitSummary': 'Verdeeld over {count} · {amount} elk', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 260d6ee3e..4c6120b9c 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} w {to}', 'costs.category': 'Kategoria', 'costs.whoPaid': 'Kto zapłacił?', + 'costs.multiplePayers': 'Zapłaciło kilka osób', + 'costs.singlePayer': 'Zapłaciła jedna osoba', + 'costs.payersUnbalanced': 'Kwoty płacących muszą sumować się do {amount}', 'costs.splitBetween': 'Podziel równo między', 'costs.pickSomeone': 'Wybierz co najmniej jedną osobę do podziału.', 'costs.splitSummary': 'Podział na {count} · {amount} na osobę', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 479d775c3..9954c1f99 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} в {to}', 'costs.category': 'Категория', 'costs.whoPaid': 'Кто заплатил?', + 'costs.multiplePayers': 'Платили несколько человек', + 'costs.singlePayer': 'Платил один человек', + 'costs.payersUnbalanced': 'Сумма платежей должна составлять {amount}', 'costs.splitBetween': 'Поделить поровну между', 'costs.pickSomeone': 'Выберите хотя бы одного человека для разделения.', 'costs.splitSummary': 'Разделено на {count} · по {amount}', diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index dc0769e9f..4ba88f96a 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} i {to}', 'costs.category': 'Kategori', 'costs.whoPaid': 'Vem betalade?', + 'costs.multiplePayers': 'Flera personer betalade', + 'costs.singlePayer': 'En person betalade', + 'costs.payersUnbalanced': 'Betalarnas belopp måste bli {amount} totalt', 'costs.splitBetween': 'Fördela jämnt mellan', 'costs.pickSomeone': 'Välj minst en person att dela med.', 'costs.splitSummary': 'Dela {count} sätt · {amount} var och en', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index b0969f862..41bdbeb4a 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '{to} cinsinden 1 {from}', 'costs.category': 'Kategori', 'costs.whoPaid': 'Kim ödedi?', + 'costs.multiplePayers': 'Birden fazla kişi ödedi', + 'costs.singlePayer': 'Tek kişi ödedi', + 'costs.payersUnbalanced': 'Ödeyenlerin tutarları toplamı {amount} olmalıdır', 'costs.splitBetween': 'Eşit olarak böl', 'costs.pickSomeone': 'Paylaşmak için en az bir kişi seçin.', 'costs.splitSummary': '{count} kişiye bölündü · her biri {amount}', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index a4e2a039f..6737e9601 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} у {to}', 'costs.category': 'Категорія', 'costs.whoPaid': 'Хто заплатив?', + 'costs.multiplePayers': 'Платили кілька осіб', + 'costs.singlePayer': 'Платила одна особа', + 'costs.payersUnbalanced': 'Сума платежів має становити {amount}', 'costs.splitBetween': 'Розділити порівну між', 'costs.pickSomeone': 'Виберіть хоча б одну особу для розподілу.', 'costs.splitSummary': 'Розділено на {count} · по {amount}', diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index 7cb92f492..d58e7ff4f 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -93,6 +93,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} trong {to}', 'costs.category': 'Loại', 'costs.whoPaid': 'Ai đã trả tiền?', + 'costs.multiplePayers': 'Nhiều người đã trả', + 'costs.singlePayer': 'Một người đã trả', + 'costs.payersUnbalanced': 'Tổng số tiền của người trả phải bằng {amount}', 'costs.splitBetween': 'Chia đều cho', 'costs.pickSomeone': 'Chọn ít nhất một người để chia tiền.', 'costs.splitSummary': 'Chia {count} người · {amount} mỗi người', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index 3b8817495..ff35c0dea 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -92,6 +92,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} 兌 {to}', 'costs.category': '分類', 'costs.whoPaid': '誰付的款?', + 'costs.multiplePayers': '多人支付', + 'costs.singlePayer': '一人支付', + 'costs.payersUnbalanced': '付款人金額總和必須為 {amount}', 'costs.splitBetween': '平均分攤給', 'costs.pickSomeone': '至少選擇一人來分攤。', 'costs.splitSummary': '分 {count} 份 · 每份 {amount}', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index ae2e02ead..b9a324baa 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -92,6 +92,9 @@ const budget: TranslationStrings = { 'costs.rateLabel': '1 {from} = {to}', 'costs.category': '分类', 'costs.whoPaid': '谁支付的?', + 'costs.multiplePayers': '多人支付', + 'costs.singlePayer': '一人支付', + 'costs.payersUnbalanced': '付款人金额总和必须为 {amount}', 'costs.splitBetween': '平均分摊给', 'costs.pickSomeone': '至少选择一人参与分摊。', 'costs.splitSummary': '分 {count} 份 · 每份 {amount}',