diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index d5ec949b1..9ebec4544 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -465,4 +465,102 @@ describe('CostsPanel — settlements in the ledger', () => { ])) expect(posted!.note).toContain('TICKETJSON:') }) + + // ── Display currency ─────────────────────────────────────────────────────── + + it('shows amounts in the trip currency when the user has no display currency set', async () => { + // No personal preference → the trip's own currency wins, instead of a hardcoded one. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: '' } }) + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'JPY' }) }) + const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Sushi' }), total_price: 3000, currency: 'JPY', payers: [], members: [{ user_id: 1, username: 'alice', 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: [] })), + ) + render() + + await screen.findByText('Sushi') + const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]') + // Yen, unconverted and with JPY's zero decimals — not a euro/dollar default. + expect(card).toHaveTextContent('¥3,000') + }) + + // ── Payment currency ─────────────────────────────────────────────────────── + // A transfer settling a shared bill can be made in any currency, so it carries its + // own rather than being assumed to be in the display one. + + it('records a payment in the display currency by default', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + 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/settlements', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ settlement: { id: 1, ...posted } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add payment' })) + await user.type(await screen.findByPlaceholderText('0.00'), '25') + const addButtons = screen.getAllByRole('button', { name: 'Add payment' }) + await user.click(addButtons[addButtons.length - 1]) + + await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'EUR' })) + }) + + it('records a payment made in another currency', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + 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/settlements', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ settlement: { id: 1, ...posted } }) + }), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await user.click(await screen.findByRole('button', { name: 'Add payment' })) + await user.type(await screen.findByPlaceholderText('0.00'), '25') + // Bob paid me back in dollars — the server freezes the USD rate on write. + await user.click(screen.getByText(/^EUR/)) + await user.click(await screen.findByText(/^USD/)) + const addButtons = screen.getAllByRole('button', { name: 'Add payment' }) + await user.click(addButtons[addButtons.length - 1]) + + await waitFor(() => expect(posted).toMatchObject({ amount: 25, currency: 'USD' })) + }) + + it('reopens a foreign-currency payment with its own currency', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } }) + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [], + flows: [], + settlements: [ + { id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, currency: 'USD', exchange_rate: 1.1, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' }, + ], + }) + ), + ) + const { default: userEvent } = await import('@testing-library/user-event') + const user = userEvent.setup() + render() + + await screen.findByText('Payment') + await user.click(screen.getByTitle('Edit')) + + // The stored USD amount comes back as-is, not silently reread as euros. + expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30') + expect(screen.getByText(/^USD/)).toBeInTheDocument() + }) }) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 98e19fe37..e407b1c41 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -94,6 +94,9 @@ interface Settlement { from_user_id: number to_user_id: number amount: number + // The currency the transfer was entered in. Legacy rows predate it (null) and are + // read as the display currency, which is what the server assumes for them too. + currency?: string | null created_at?: string from_username?: string to_username?: string @@ -775,18 +778,23 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // A settle-up payment as a ledger row — visually distinct from an expense, with // inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal. function SettlementRow({ s }: { s: Settlement }) { + // Legacy transfers carry no currency and were entered in the display base. + const cur = (s.currency || base).toUpperCase() return (
-
{t('costs.payment')}
+
+ {t('costs.payment')} + {cur !== base && · {fmt(s.amount, cur)} → {fmt(convert(s.amount, cur))}} +
{personName(s.from_user_id)} → {personName(s.to_user_id)}
-
{fmt(s.amount)}
+
{fmt(convert(s.amount, cur))}
{canEdit && (
@@ -902,9 +910,12 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A ) } -// Add or edit a settle-up payment (from / to / amount). Reachable inline from the -// ledger row and from a manual "Add payment" button, so recording "I sent money to -// X" works the same whether or not there's an outstanding expense behind it. +// Add or edit a settle-up payment (from / to / amount / currency). Reachable inline +// from the ledger row and from a manual "Add payment" button, so recording "I sent +// money to X" works the same whether or not there's an outstanding expense behind it. +// A transfer can be made in any currency — paying a rouble debt in euros is normal — +// so it carries its own, defaulting to the display currency. The server freezes its +// FX rate on write, the same way an expense's is frozen. function SettlementModal({ tripId, people, me, editing, currency, onClose, onSaved }: { tripId: number; people: TripMember[]; me: number; editing: Settlement | null; currency: string; onClose: () => void; onSaved: () => void }) { @@ -914,6 +925,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const [fromId, setFromId] = useState(String(editing?.from_user_id ?? me)) const [toId, setToId] = useState(String(editing?.to_user_id ?? otherDefault)) const [amount, setAmount] = useState(editing ? String(editing.amount) : '') + const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) const [saving, setSaving] = useState(false) const amt = parseFloat(amount) || 0 @@ -923,7 +935,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const save = async () => { if (!valid) return setSaving(true) - const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency } + const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur } try { if (editing) await budgetApi.updateSettlement(tripId, editing.id, data) else await budgetApi.createSettlement(tripId, data) @@ -931,7 +943,6 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav } catch { toast.error(t('common.unknownError')) } finally { setSaving(false) } } - const inputCls = 'w-full bg-surface-input border border-edge text-content' const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' return ( @@ -951,10 +962,22 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav setToId(String(v))} options={opts} style={{ width: '100%' }} />
-
- - setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none', fontWeight: 600 }} /> +
+
+ +
+ {SYMBOLS[cur] || (cur + ' ')} + setAmount(e.target.value.replace(',', '.'))} + className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} /> +
+
+
+ + setCur(String(v))} searchable + options={currenciesWith(cur).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} + style={{ width: '100%' }} /> +