mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(budget): add back the multi payer selection
This commit is contained in:
@@ -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' } })
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
@@ -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} لكل واحد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ý',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} το καθένα',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ę',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user