From 8367b6dbe9853d43ca3814f7fc0209e5b29d6ae6 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 22:22:25 +0200 Subject: [PATCH] feat(budget): give settle-up payments their own currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transfer settling a shared bill can be made in any currency — paying a rouble debt in euros is normal — and the server has stored `currency` + a frozen `exchange_rate` on every transfer since #1445, re-freezing it on edit. The UI just never let anyone choose one, so a payment silently inherited whatever the viewer's display currency happened to be. Add the picker to the payment modal, mirroring the expense modal, and reopen an existing payment in the currency it was actually recorded in. The ledger row now shows a foreign payment as `$30.00 -> 27,00 EUR` like a foreign expense does, instead of stamping the display currency's symbol onto the raw stored number. The Settle buttons on the suggested flows keep sending the display currency: those amounts are computed in it. --- .../src/components/Budget/CostsPanel.test.tsx | 98 +++++++++++++++++++ client/src/components/Budget/CostsPanel.tsx | 45 ++++++--- 2 files changed, 132 insertions(+), 11 deletions(-) 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%' }} /> +