fix(budget): add back the multi payer selection

This commit is contained in:
jubnl
2026-07-13 20:31:48 +02:00
parent c3fbfaf1e9
commit 5e3cc7a0e1
27 changed files with 514 additions and 12 deletions
@@ -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)
})
})
@@ -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<number, string>, ids: Set<number>): 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<number, string>, ids: Set<number>, 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<number, string>,
pinned: Set<number>,
ids: Set<number>,
total: number,
): Record<number, string> {
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
}
@@ -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<string, unknown> | 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<string, unknown>
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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
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<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item: buildBudgetItem({ trip_id: 1, name: 'Dinner' }) })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
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<string, unknown> | 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<string, unknown>
return HttpResponse.json({ item })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
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' } })
+133 -12
View File
@@ -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<Set<number>>(() =>
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<number>(() => {
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<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
const m: Record<number, string> = {}
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<Set<number>>(() => 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<number>([payerId > 0 ? payerId : me])
const pinned = new Set<number>()
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
</div>
<div>
<label className={labelCls}>{t('costs.whoPaid')}</label>
<CustomSelect value={String(payerId)} onChange={v => 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%' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
className="text-content-muted"
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textDecoration: 'underline' }}>
{multiPayer ? t('costs.singlePayer') : t('costs.multiplePayers')}
</button>
</div>
{!multiPayer ? (
<CustomSelect value={String(payerId)} onChange={v => 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%' }} />
) : (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{people.map((p, idx) => {
const on = payerIds.has(p.id)
return (
<div key={p.id} className="bg-surface-secondary border border-edge"
style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
<button type="button" onClick={() => togglePayer(p.id)} data-testid="payer-toggle"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
{p.avatar_url
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>
{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}
</span>}
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.id === me ? t('costs.you') : p.username}
</span>
</button>
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder="0.00" data-testid="payer-amount"
value={payerAmounts[p.id] || ''}
onValueChange={v => 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' }} />
</div>
) : (
<button type="button" onClick={() => togglePayer(p.id)} className="text-content-faint"
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', textAlign: 'right' }}>
{t('costs.tapToInclude')}
</button>
)}
</div>
)
})}
</div>
{!payersOk && (
<div style={{ marginTop: 8, fontSize: 'calc(12.5px * var(--fs-scale-caption, 1))', color: '#d97706' }}>
{t('costs.payersUnbalanced', { amount: formatMoney(totalNum, currency, locale) })}
</div>
)}
</>
)}
</div>
<div>
@@ -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) ───────────────────────────
+3
View File
@@ -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} لكل واحد',
+3
View File
@@ -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',
+3
View File
@@ -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ý',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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} το καθένα',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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ę',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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}',
+3
View File
@@ -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}',
+3
View File
@@ -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',
+3
View File
@@ -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}',
+3
View File
@@ -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}',