mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(budget): give settle-up payments their own currency
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.
This commit is contained in:
@@ -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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
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<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/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
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 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<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/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
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 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(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, marginBottom: 6 }}>
|
||||
{t('costs.payment')}
|
||||
{cur !== base && <span className="text-content-faint" style={{ fontWeight: 400, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}> · {fmt(s.amount, cur)} → {fmt(convert(s.amount, cur))}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(convert(s.amount, cur))}</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
@@ -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>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
const [cur, setCur] = useState<string>((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
|
||||
<label className={labelCls}>{t('costs.to')}</label>
|
||||
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => 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 }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>{SYMBOLS[cur] || (cur + ' ')}</span>
|
||||
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||
onChange={e => 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%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label className={labelCls}>{t('costs.currency')}</label>
|
||||
<CustomSelect value={cur} onChange={v => setCur(String(v))} searchable
|
||||
options={currenciesWith(cur).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user