diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 0c099d0be..9105a0301 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -875,10 +875,11 @@ describe('CostsPanel — overview', () => { const alice = within(card).getByRole('button', { name: /You/ }) await user.click(alice) expect(alice).toHaveAttribute('aria-expanded', 'true') - // The dinner is the server's 92 €, once as the line and once as its only row; - // the raw 100 USD never shows up in the card. + // The dinner is the server's 92 €, once as the line and once as its only row; the + // 100 USD is never converted again here. What was entered labels the row (#2525). expect(within(card).getAllByText('+92,00 €')).toHaveLength(2) expect(within(card).queryByText(/100,00/)).toBeNull() + expect(within(card).getByText('· $100.00')).toBeInTheDocument() // Received and still pending both lower her cost, each line with its one row. expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) expect(within(card).getAllByText('−16,00 €')).toHaveLength(2) @@ -2293,3 +2294,234 @@ describe('CostsPanel — expense modal in another language', () => { expect(screen.queryByText('Excluded')).not.toBeInTheDocument() }) }) + +// ── #2525: a bill entered in the display currency on a trip kept in another ─── +describe('CostsPanel: a bill entered in the display currency (#2525)', () => { + // The reporter's setup: a euro trip, read in dollars, with the bill entered in + // dollars. It was booked at 1.17 dollars to the euro, 685.26 EUR, and that is what + // the balances and settle-up net. Today the euro buys 1.1551 dollars, so those + // euros are $791.55. The dollar's own quote is rounded separately (0.865706 per + // dollar is 1.155126 per euro), which is why the panel converts with the euro's. + beforeEach(() => { + seedAlice() + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + clearExchangeRateCache() + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551, SEK: 10 }, ts: Date.now() })) + }) + afterEach(clearExchangeRateCache) + + const hotel = (over: Partial = {}) => expense({ + id: 401, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + expense_date: '2026-08-06', + payers: [{ user_id: 1, amount: 801.76 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + ...over, + }) + + it('FE-W5COSTS-079: a bill whose rate moved shows what was entered and what it was booked at', async () => { + mount([hotel()]) + + await screen.findByText('Aparthotel Silver') + // The entered amount was nowhere on the row, so $791.57 read as a wrong copy of + // the 801.76 in the dialog. The pill beside the line is the last step. + expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument() + // Row total and payer chip stay the booked euros at today's rate, and so does what + // I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here + // had Bob pay back more than he owed and reopened the trip. + expect(screen.getAllByText('$791.55')).toHaveLength(2) + expect(screen.getByText('you lent $395.77')).toBeInTheDocument() + expect(screen.queryByText('you lent $400.88')).toBeNull() + }) + + it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => { + mount([ + hotel({ id: 407, name: 'Villa', total_price: 12345.67, exchange_rate: 1.1551, payers: [{ user_id: 1, amount: 12345.67 }] }), + hotel({ id: 408, name: 'Unpaid deposit', total_price: 250, exchange_rate: 1.1551, payers: [], members: [] }), + ]) + + await screen.findByText('Villa') + // Pill and payer chip; converting back with the dollar's own quote gave $12,346.05. + expect(screen.getAllByText('$12,345.67')).toHaveLength(2) + expect(screen.queryByText(/12,346\.0[56]/)).toBeNull() + expect(screen.getByText('$12,595.67 spent')).toBeInTheDocument() + const total = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + expect(total.textContent).toContain('$12,595.67') + const outstanding = screen.getByText('Outstanding amount').parentElement!.parentElement!.parentElement! + expect(outstanding.textContent).toContain('$250.00') + // Nothing to explain under either row. + expect(screen.queryByText(/^\$12,345\.67 →/)).toBeNull() + expect(screen.queryByText(/^\$250\.00 →/)).toBeNull() + }) + + it('FE-W5COSTS-088: the CSV export adds the trip-currency amount every sum is built from', async () => { + let exported: Blob | null = null + const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(b => { exported = b as Blob; return 'blob:mock' }) + const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + try { + mount([hotel()]) + await screen.findByText('Aparthotel Silver') + fireEvent.click(screen.getByTitle('Export CSV')) + await waitFor(() => expect(exported).toBeTruthy()) + const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n') + expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note') + expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/) + } finally { + createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore() + } + }) + + it('FE-W5COSTS-080: the totals add up the figures the rows show', async () => { + mount([ + hotel(), + hotel({ id: 402, name: 'Dinner', category: 'food', total_price: 120, payers: [{ user_id: 2, amount: 120 }] }), + ]) + + await screen.findByText('Aparthotel Silver') + // 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is + // $910.02, on the day header and on the total spend card. + expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument() + expect(screen.getByText('$910.02 spent')).toBeInTheDocument() + // Label, its text block, the header row, then the card itself. + const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + expect(card.textContent).toContain('$910.02') + }) + + it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => { + // Nothing cached: the rates land after the expenses and the settlement, and the + // totals used to keep the figure they were first added up with. + clearExchangeRateCache() + server.use(http.get('https://api.frankfurter.dev/v2/rates', async () => { + await new Promise(r => setTimeout(r, 150)) + return HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + })) + mount([hotel({ id: 406, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })]) + + // 100 euro at 0.8 euro to the dollar. + expect(await screen.findByText(/100,00\s€ → \$125\.00/)).toBeInTheDocument() + const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement! + await waitFor(() => expect(card.textContent).toContain('$125.00')) + }) + + it('FE-W5COSTS-081: a euro row converts live, a third currency shows the euros it was booked at', async () => { + mount([ + hotel({ id: 403, name: 'Museum', category: 'activities', total_price: 100, currency: 'EUR', exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] }), + hotel({ id: 404, name: 'Ferry', category: 'transport', total_price: 1000, currency: 'SEK', exchange_rate: 10, payers: [{ user_id: 1, amount: 1000 }] }), + // Written before the freeze existed: nothing was booked, so dollars stay dollars. + hotel({ id: 405, name: 'Old taxi', category: 'transport', total_price: 50, currency: 'USD', exchange_rate: 1, payers: [{ user_id: 1, amount: 50 }] }), + ]) + + await screen.findByText('Museum') + // The trip currency has no frozen rate of its own and goes to dollars live. + expect(screen.getByText(/^100,00\s€ → \$115\.51$/)).toBeInTheDocument() + // 1000 SEK at 10 per euro is 100 euro, then live to dollars in the pill. + expect(screen.getByText(/^1\s000,00\skr → 100,00\s€$/)).toBeInTheDocument() + expect(screen.getAllByText('$50.00').length).toBeGreaterThanOrEqual(2) + expect(screen.queryByText(/^\$50\.00 →/)).toBeNull() + }) + + it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => { + mount([hotel()], { + settlements: [ + { id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' }, + // Rows from before transfers had a currency were entered in the display + // currency, which is how the server settles them. + { id: 22, from_user_id: 2, to_user_id: 1, amount: 30, currency: null, created_at: '2026-08-08 09:00:00' }, + ], + }) + + await screen.findByText('Aparthotel Silver') + expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument() + expect(screen.getByText('$395.77')).toBeInTheDocument() + expect(screen.getByText('$30.00')).toBeInTheDocument() + expect(screen.queryByText('$34.65')).toBeNull() + }) + + it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => { + const user = userEvent.setup() + mount([hotel()]) + + await screen.findByText('Aparthotel Silver') + await user.click(screen.getByTitle('Edit')) + expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument() + // Same currency as the list, so the dialog said nothing, and 801.76 there beside + // $791.57 in the list looked like two different expenses. + const hint = screen.getByText(/live rate/).parentElement as HTMLElement + expect(within(hint).getByText('$801.76')).toBeInTheDocument() + expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument() + expect(within(hint).getByText('$791.55')).toBeInTheDocument() + // Each share next to what it counts as, the figure the row's "you lent" is made of. + expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument() + }) + + it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => { + const user = userEvent.setup() + mount([hotel()], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 395.78 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -395.78 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 395.78 }], + finalBudgets: [ + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 791.55, reimbursed: 0, pending: 395.78, final: 395.77, + sources: { fronted: [{ item_id: 401, cents: 79155 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 39578 }] }, + }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await user.click(await within(card).findByRole('button', { name: /You/ })) + expect(within(card).getAllByText('+$791.55')).toHaveLength(2) + expect(within(card).getByText('Aparthotel Silver')).toBeInTheDocument() + expect(within(card).getByText('· $801.76')).toBeInTheDocument() + }) + + it('FE-W5COSTS-089: the final budget lists a bill that reads as typed without a second amount', async () => { + const user = userEvent.setup() + mount([hotel({ exchange_rate: 1.1551 })], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 400.88 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -400.88 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 400.88 }], + finalBudgets: [ + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 801.76, reimbursed: 0, pending: 400.88, final: 400.88, + sources: { fronted: [{ item_id: 401, cents: 80176 }], moved: [], outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 40088 }] }, + }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await user.click(await within(card).findByRole('button', { name: /You/ })) + expect(within(card).getAllByText('+$801.76')).toHaveLength(2) + expect(within(card).queryByText('· $801.76')).toBeNull() + }) + + it('FE-W5COSTS-083: an expense saved without a currency opens in the trip currency, not the display one', async () => { + const user = userEvent.setup() + let put: Record | null = null + server.use(http.put('/api/trips/1/budget/405', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ item: hotel({ id: 405, currency: 'EUR' }) }) + })) + mount([hotel({ id: 405, name: 'Tram pass', category: 'transport', total_price: 100, currency: null, exchange_rate: 1, payers: [{ user_id: 1, amount: 100 }] })]) + + await screen.findByText('Tram pass') + // The list reads the missing currency as the trip's own. + expect(screen.getByText(/100,00\s€ → \$115\.51/)).toBeInTheDocument() + + await user.click(screen.getByTitle('Edit')) + expect(await screen.findByDisplayValue('100,00')).toBeInTheDocument() + // Seeding the display currency here would label 100 euro as 100 dollars, and a + // plain save would then store it that way. + expect(screen.getByText(/^EUR/)).toBeInTheDocument() + expect(screen.queryByText(/^USD/)).toBeNull() + + // Saving without touching anything keeps the 100 euro. + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(put).toMatchObject({ total_price: 100, currency: 'EUR' })) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 95e2d5850..de8a4b03f 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -9,7 +9,8 @@ import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' import { saveWithReceipts } from './receiptUploads' -import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf, useExchangeRates } from '../../hooks/useExchangeRates' +import { splitShareLabel, useExpenseFx } from './expenseFx' import { useIsMobile } from '../../hooks/useIsMobile' import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' import { downloadBlob, openFile } from '../../utils/fileDownload' @@ -85,7 +86,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() // Pre-rework rows stored currency = NULL, meaning "the trip's own currency". const tripCurrency = (trip?.currency || base).toUpperCase() - const { convert } = useExchangeRates(base) + // Anchored on the trip currency's quote, the one the server books with (#2525). + const { convert } = useExchangeRates(base, tripCurrency) const curOf = useCallback((e: BudgetItem) => (e.currency || tripCurrency), [tripCurrency]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, which the empty views would @@ -145,12 +147,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps [convert, tripCurrency], ) const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e) - // A transfer freezes its own rate at settle time, in its own table (#1445). + // A transfer freezes its own rate at settle time, in its own table (#1445). One + // without a currency predates that and was entered in the display currency, which + // is how the server settles it too, not in the trip's. const settled = useCallback( - (s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert), - [convert, tripCurrency], + (s: Settlement) => convertBooked(s.amount, s.currency || base, s.exchange_rate, tripCurrency, convert), + [convert, tripCurrency, base], ) const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), e) + // The line under an amount shown converted: what was entered, then where it went (#2525). + const lineOf = (amount: number, e: BudgetItem, shown: number) => + convertedLine(amount, e.currency, e.exchange_rate, tripCurrency, base, shown) // "Unfinished": a recorded total nobody has paid yet — counts toward the trip // total but stays out of settlements until who-paid is filled in. A negative // total (a refund, #2176) is just as unfinished until its recipient is named. @@ -169,6 +176,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps return booked(myShare, e) } + // `booked` carries the rates. They can land after the expenses, and without it in the + // deps the cards kept the sums they were first added up with while the rows moved on. const totals = useMemo(() => { const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0) const myPaid = budgetItems.reduce((a, e) => a + myPaidOf(e), 0) @@ -178,7 +187,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const outstanding = budgetItems.reduce((a, e) => (isUnfinished(e) ? a + baseTotal(e) : a), 0) const outstandingCount = budgetItems.filter(isUnfinished).length return { totalSpend, myPaid, myShare, owe, owed, outstanding, outstandingCount } - }, [budgetItems, settlement, me]) + }, [budgetItems, settlement, me, booked]) // ── filtering + day grouping ──────────────────────────────────────────── const filtered = useMemo(() => { @@ -192,7 +201,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const q = search.trim().toLowerCase() if (q) list = list.filter(e => e.name.toLowerCase().includes(q)) return list - }, [budgetItems, filter, search, catFilter, dayFilter, me]) + }, [budgetItems, filter, search, catFilter, dayFilter, me, booked]) // Settlements ("payments") shown inline in the ledger. They have no name, so a // text search hides them; they're excluded from the "owed" expense filter and, @@ -290,15 +299,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } const fmtDate = (iso: string) => { if (!iso) return ''; try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } catch { return iso } } - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', 'Amount (' + base + ')', 'Note'] + // Read in another currency than the trip's, what each row counts as in the trip + // currency too, the figure every sum is built from (#2525). + const tripCol = tripCurrency !== base + const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? ['Amount (' + tripCurrency + ')'] : []), 'Amount (' + base + ')', 'Note'] const rows = [header.join(sep)] const items = budgetItems.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) for (const e of items) { const cur = curOf(e) const note = readUserNote(e) + const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, tripCurrency, convert) rows.push([ esc(fmtDate(e.expense_date || '')), esc(e.name), esc(t(catMeta(e.category).labelKey)), (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, + ...(tripCol ? [inTrip.toFixed(currencyDecimals(tripCurrency))] : []), baseTotal(e).toFixed(currencyDecimals(base)), esc(note), ].join(sep)) @@ -768,7 +782,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps function ExpenseRow({ e }: { e: BudgetItem }) { const c = catMeta(e.category) const Icon = c.Icon - const cur = curOf(e) + const line = lineOf(e.total_price || 0, e, baseTotal(e)) const payers = (e.payers || []).filter(p => p.amount !== 0) const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) @@ -837,9 +851,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )} - {cur !== base && ( + {line && (
- {fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))} + {fmt(line.entered.amount, line.entered.currency)} {'→'} {fmt(line.into.amount, line.into.currency)}
)} {/* Under the name, because a chip reading "Y 122,00" says nothing on @@ -894,6 +908,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps function SettlementRow({ s }: { s: Settlement }) { // Legacy transfers carry no currency and were entered in the display base. const cur = (s.currency || base).toUpperCase() + // Booked in the trip currency like an expense, so it is explained the same way. + const line = convertedLine(s.amount, cur, s.exchange_rate, tripCurrency, base, settled(s)) return (
@@ -901,7 +917,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.payment')} - {cur !== base && · {fmt(s.amount, cur)} → {fmt(settled(s))}} + {line && · {fmt(line.entered.amount, line.entered.currency)} → {fmt(line.into.amount, line.into.currency)}}
@@ -1000,6 +1016,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // server's display cents, not a conversion of the expense list done here. const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v)) const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems) + // Beside a converted row's name, what was entered, so the breakdown names the bill + // the way the list above it does rather than by a figure nobody typed. + const enteredOf = (itemId: number, shown: number) => { + const e = budgetItems.find(i => i.id === itemId) + const line = e ? lineOf(paidByUser(e, row.user_id), e, shown) : null + return line ? fmt(line.entered.amount, line.entered.currency) : null + } const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const const detail = (label: string, value: string) => (
@@ -1022,12 +1045,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.finalExpenses')}
- {fronted.map(r => ( -
- {r.name} - {signed(r.amount)} -
- ))} + {fronted.map(r => { + const entered = enteredOf(r.item_id, r.amount) + return ( +
+ + {r.name}{entered && · {entered}} + + {signed(r.amount)} +
+ ) + })}
)} @@ -1237,12 +1265,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const toast = useToast() const isMobile = useIsMobile() const { addBudgetItem, updateBudgetItem } = useTripStore() - const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') + // A saved expense without a currency opens in the trip's own (#2525). + const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) const [name, setName] = useState(editing?.name || prefill?.name || '') const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) - const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) + const [currency, setCurrency] = useState(editingCurrency) const [day, setDay] = useState(editing?.expense_date || localToday()) const [note, setNote] = useState(() => readUserNote(editing)) // Edit and prefill seeds are padded to the currency's decimals (#2175): the DB @@ -1250,7 +1279,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo // 5,00 as "5". A prefill has no currency of its own — it is read as `base`, // which is also what the currency field starts on. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) @@ -1334,6 +1363,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }, [ticketItems]) const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const fx = preview(totalNum, currency) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) // How much is still to be handed out, read on the total's own side: on a refund @@ -1591,12 +1621,18 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- {currency !== base && totalNum !== 0 && ( + {fx && (
{formatMoney(totalNum, currency, locale)} - ≈ - {formatMoney(convert(totalNum, currency), base, locale)} - · {t('costs.liveRate')} + {fx.inTrip != null && <> + → + {formatMoney(fx.inTrip, tripCur, locale)} + } + {fx.shown != null && <> + ≈ + {formatMoney(fx.shown, base, locale)} + · {t('costs.liveRate')} + }
)} @@ -1823,7 +1859,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{splitMode === 'equally' ? ( - {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })} ) : ( diff --git a/client/src/components/Budget/expenseFx.test.ts b/client/src/components/Budget/expenseFx.test.ts new file mode 100644 index 000000000..db6b10748 --- /dev/null +++ b/client/src/components/Budget/expenseFx.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { formatMoney } from '../../utils/formatters' +import { editingCurrencyOf, expenseFxPreview, splitShareLabel } from './expenseFx' + +// A euro trip read in dollars, converted with the euro's own quote: one euro is 1.1551 +// dollars today, and 1.17 on the day the hotel was booked (#2525). +const convert = (amount: number, from: string | null | undefined): number => + (amount / ({ EUR: 1, USD: 1.1551, JPY: 170 } as Record)[(from || 'USD').toUpperCase()]) * 1.1551 +const hotel = { currency: 'USD', exchange_rate: 1.17 } +const base = { editing: hotel, editingCurrency: 'USD', tripCurrency: 'EUR', base: 'USD', convert } + +describe('editingCurrencyOf', () => { + it('opens a saved expense without a currency in the trip currency', () => { + expect(editingCurrencyOf({ currency: null }, 'eur', 'USD')).toBe('EUR') + expect(editingCurrencyOf({ currency: 'jpy' }, 'EUR', 'USD')).toBe('JPY') + }) + + it('starts a new expense, or one on a trip without a currency, in the display currency', () => { + expect(editingCurrencyOf(null, 'EUR', 'usd')).toBe('USD') + expect(editingCurrencyOf({ currency: null }, null, 'usd')).toBe('USD') + }) +}) + +describe('expenseFxPreview', () => { + it('previews an edit that keeps the currency at the rate frozen on entry', () => { + const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })! + expect(fx.inTrip).toBeCloseTo(685.26, 2) + expect(fx.shown).toBeCloseTo(791.55, 2) + }) + + it('has nothing to preview for a bill in the display currency that reads back as typed', () => { + expect(expenseFxPreview({ ...base, editing: { currency: 'USD', exchange_rate: 1.1551 }, total: 12345.67, currency: 'USD' })).toBeNull() + expect(expenseFxPreview({ ...base, editing: null, total: 250, currency: 'USD' })).toBeNull() + expect(expenseFxPreview({ ...base, total: 0, currency: 'USD' })).toBeNull() + }) + + it('previews a new or re-currencied amount at today\'s rate', () => { + expect(expenseFxPreview({ ...base, editing: null, total: 1700, currency: 'JPY' })).toEqual({ inTrip: null, shown: convert(1700, 'JPY') }) + // Switching the hotel to yen re-freezes on save, so the old rate does not apply. + expect(expenseFxPreview({ ...base, total: 1700, currency: 'JPY' })?.inTrip).toBeNull() + }) + + it('stops at the trip amount when the viewer reads the trip currency', () => { + expect(expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })).toEqual({ inTrip: 801.76 / 1.17, shown: null }) + }) +}) + +describe('splitShareLabel', () => { + const sym = (c: string) => (c === 'USD' ? '$' : c + ' ') + + it('prints the entered share, then the share the row lends or borrows', () => { + const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' }) + expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', sym, 'en-US')).toBe('$400.88 → $395.77') + // Read in the trip currency, the share is the booked euros. + const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' }) + expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', sym, 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`) + }) + + it('prints the entered share alone when nothing is converted', () => { + expect(splitShareLabel(125, 'USD', null, 2, 'USD', sym, 'en-US')).toBe('$125.00') + expect(splitShareLabel(0, 'USD', { inTrip: null, shown: 10 }, 0, 'USD', sym, 'en-US')).toBe('$0.00') + }) +}) diff --git a/client/src/components/Budget/expenseFx.ts b/client/src/components/Budget/expenseFx.ts new file mode 100644 index 000000000..3619bed06 --- /dev/null +++ b/client/src/components/Budget/expenseFx.ts @@ -0,0 +1,87 @@ +import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates' +import { useTripStore } from '../../store/tripStore' +import { currencyDecimals, formatMoney } from '../../utils/formatters' + +/** + * The conversion preview of the expense editors, shared by the desktop ExpenseModal and the + * phone MCostSheet so the two can never preview the same save differently (#2525). The + * editors only lay it out. + */ + +interface EditedExpense { + currency?: string | null + exchange_rate?: number | null +} + +/** + * The currency an edited expense opens in. A saved expense without a currency is in the + * trip's own, which is how the lists read it; opening it in the display currency relabelled + * the amount, and a save then stored it in that currency. + */ +export function editingCurrencyOf(editing: EditedExpense | null | undefined, tripCurrency: string | null | undefined, base: string): string { + return ((editing && (editing.currency || tripCurrency)) || base).toUpperCase() +} + +export interface ExpenseFx { + /** The amount in the trip currency, when the save keeps a rate frozen on entry. */ + inTrip: number | null + /** The amount in the display currency at today's rate; null when `inTrip` already is. */ + shown: number | null +} + +/** + * What the amount being edited will count as. An edit that keeps the currency keeps the rate + * frozen when the expense was entered (the server re-freezes only when the currency changes), + * so it is previewed at that rate, the way the list will show it after the save. Anything + * else is previewed at today's rate. Null when there is nothing to convert, including a bill + * in the display currency that still reads back as typed. + */ +export function expenseFxPreview(args: { + total: number + currency: string + editing: EditedExpense | null | undefined + editingCurrency: string + tripCurrency: string + base: string + convert: (amount: number, from: string | null | undefined) => number +}): ExpenseFx | null { + const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args + if (total === 0) return null + const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null + if (currency === base && inTrip == null) return null + const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency) + const unit = 10 ** currencyDecimals(base) + if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null + return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown } +} + +/** + * One equal share as the split summary prints it, "$400.88", followed by what that share + * counts as when the amount is converted, "$400.88 → $390.54". That second figure is the + * one the row's "you lent" is made of, so the editor and the row cannot disagree. + */ +export function splitShareLabel( + each: number, + currency: string, + fx: ExpenseFx | null, + participants: number, + base: string, + sym: (currency: string) => string, + locale: string, +): string { + const entered = sym(currency) + each.toFixed(2) + const whole = fx ? (fx.shown ?? fx.inTrip) : null + if (whole == null || participants <= 0) return entered + return `${entered} → ${formatMoney(whole / participants, base, locale)}` +} + +/** Everything an expense editor needs to preview a conversion, from the trip in the store. */ +export function useExpenseFx(base: string, editing: EditedExpense | null | undefined) { + const storedTripCurrency = useTripStore(s => s.trip?.currency) + const tripCurrency = (storedTripCurrency || base).toUpperCase() + const { convert } = useExchangeRates(base, tripCurrency) + const editingCurrency = editingCurrencyOf(editing, storedTripCurrency, base) + const preview = (total: number, currency: string) => + expenseFxPreview({ total, currency, editing, editingCurrency, tripCurrency, base, convert }) + return { tripCurrency, editingCurrency, preview } +} diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx index b8a491985..2234c288b 100644 --- a/client/src/components/Planner/BookingCostsSection.tsx +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -33,6 +33,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = ' const trip = useTripStore(s => s.trip) const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() + // An amount is printed in its own currency, unconverted. One saved without a + // currency is in the trip's own (#2525), which is how Costs reads it; labelling + // it with the display currency turned a 120 EUR deposit into $120.00. + const ownCurrency = (currency: string | null | undefined) => currency || trip?.currency || base const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : placeId @@ -54,7 +58,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
{t(meta.labelKey)}
{t(hintKey)}
- {formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)} + {formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}
) @@ -72,7 +76,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
{linked.name}
{t(meta.labelKey)}
- {formatMoney(linked.total_price, linked.currency || base, locale)} + {formatMoney(linked.total_price, ownCurrency(linked.currency), locale)} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 61bff07bd..884f311a5 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-105 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1026,7 +1026,7 @@ describe('ReservationModal', () => { expect(screen.getByText('Linked expense')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); - expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation' }); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 240, category: 'accommodation', currency: 'EUR' }); }); it('FE-PLANNER-RESMODAL-061: a prefill without a price creates no cost entry', async () => { @@ -1336,6 +1336,55 @@ describe('ReservationModal', () => { delete window.__addToast; }); + it('FE-PLANNER-RESMODAL-103: a linked cost saved without a currency is shown in the trip currency, not the display one (#2525)', () => { + // A booking's own cost entry is written without a currency, which means the + // trip's. Reading in USD on a EUR trip must not turn the 120 EUR into $120.00. + budgetEnabled(); + seedStore(useSettingsStore, { settings: { default_currency: 'USD' } }); + seedStore(useTripStore, { + trip: buildTrip({ id: 1, currency: 'EUR' }), + budgetItems: [ + { id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null }, + ], + }); + render( + , + ); + + expect(screen.getByText('Hotel deposit')).toBeInTheDocument(); + expect(screen.getByText('120,00 €')).toBeInTheDocument(); + expect(screen.queryByText('$120.00')).toBeNull(); + }); + + it('FE-PLANNER-RESMODAL-104: an imported price keeps the currency it was quoted in, in the preview and on save (#2525)', async () => { + // A euro trip, a confirmation priced in dollars. The preview said $801.76 and the + // save sent the amount alone, which the server stored as 801.76 EUR. + budgetEnabled(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + const onSave = vi.fn().mockResolvedValue({ id: 82 }); + const prefill = hotelPrefill({ metadata: { price: 801.76, priceCurrency: 'usd' } }); + render(); + + expect(screen.getByText('$801.76')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 801.76, category: 'accommodation', currency: 'USD' }); + }); + + it('FE-PLANNER-RESMODAL-105: a parsed currency that is not a code previews and saves in the trip currency', async () => { + budgetEnabled(); + seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) }); + const onSave = vi.fn().mockResolvedValue({ id: 83 }); + const prefill = hotelPrefill({ metadata: { price: 50, priceCurrency: 'dollars' } }); + render(); + + // The server drops such a currency too, so the preview names the one it will store. + expect(screen.getByText('50,00 €')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 50, category: 'accommodation' }); + }); + // ── File error paths ──────────────────────────────────────────────────────── it('FE-PLANNER-RESMODAL-077: a cancelled file dialog changes nothing', () => { diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index ea708266f..6dbdc59ad 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -19,6 +19,7 @@ import { parseReservationMetadata } from '../../utils/flightLegs' import { resolveDayId } from '../../utils/formatters' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types' import { BookingCostsSection } from './BookingCostsSection' +import { importedPriceEntry } from './importedPrice' import { TravelerPicker } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -306,11 +307,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p // Imported booking → auto-create the linked cost from the parsed price (what the // old direct import did). Only on create (not edit) and only when there's a price. if (!reservation && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) saveData.create_budget_entry = entry } const saved = await onSave(saveData) // Persist the traveler assignment once we have the reservation id (create → save @@ -352,12 +350,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } } - // On an import review (not yet saved), preview the parsed price as the cost that will be linked. - const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null - const prefillPrice = Number(prefillMeta?.price) - const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 - ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } - : null + // On an import review (not yet saved), preview the parsed price as the cost that will be + // linked: the same entry the save sends, so the two cannot name different currencies. + const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null + const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null const handleFileChange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0] diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 419601716..957dc378f 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1090,7 +1090,9 @@ describe('TransportModal', () => { await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); - expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights' }); + // The parsed currency travels with the price (#2525), or the server would store + // it in whatever the trip currency is. + expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' }); }); // ── File edge cases ───────────────────────────────────────────────────────── diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 35815780c..197a12e45 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -18,6 +18,7 @@ import apiClient from '../../api/client' import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' +import { importedPriceEntry } from './importedPrice' import { TravelerPicker } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -612,11 +613,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Imported booking → auto-create the linked cost from the parsed price (what the // old direct import did). Only on create (not edit) and only when there's a price. if (!reservation && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - ;(payload as Record).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) (payload as Record).create_budget_entry = entry } const saved = await onSave(payload) // Persist the traveler assignment once we have the reservation id (create → save @@ -660,12 +658,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } } - // On an import review (not yet saved), preview the parsed price as the cost that will be linked. - const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null - const prefillPrice = Number(prefillMeta?.price) - const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 - ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } - : null + // On an import review (not yet saved), preview the parsed price as the cost that will be + // linked: the same entry the save sends, so the two cannot name different currencies. + const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null + const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] diff --git a/client/src/components/Planner/importedPrice.ts b/client/src/components/Planner/importedPrice.ts new file mode 100644 index 000000000..bca5f52c5 --- /dev/null +++ b/client/src/components/Planner/importedPrice.ts @@ -0,0 +1,25 @@ +import { typeToCostCategory } from '@trek/shared' + +/** + * The linked cost an imported booking's parsed price becomes, or null when it has none. + * + * The price keeps the currency it was quoted in (#2525). The forms used to send the + * amount alone, so an imported $801.76 was stored as 801.76 in the trip's own currency + * while the form had just previewed it in dollars. A currency that is not a three-letter + * code is left out, which is what the server does with one too, so the preview and the + * saved cost read the same either way. + */ +export function importedPriceEntry( + metadata: unknown, + type: string, +): { total_price: number; category: string; currency?: string } | null { + const meta = metadata && typeof metadata === 'object' ? (metadata as Record) : {} + const price = Number(meta.price) + if (!Number.isFinite(price) || price <= 0) return null + const code = typeof meta.priceCurrency === 'string' ? meta.priceCurrency.trim().toUpperCase() : '' + return { + total_price: price, + category: typeToCostCategory(type), + ...(/^[A-Z]{3}$/.test(code) ? { currency: code } : {}), + } +} diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index 45eaac209..1eca924b3 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { server } from '../../tests/helpers/msw/server' -import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates' +import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, clearExchangeRateCache, tripAmountOf, useExchangeRates } from './useExchangeRates' const FX_URL = 'https://api.frankfurter.dev/v2/rates' @@ -92,4 +93,145 @@ describe('convertBooked', () => { expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100) expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100) }) + + // #2525: a euro trip read in dollars, the bill entered in dollars. It was booked at 685.26 + // EUR, and that is what the balances and settle-up net and then show at today's rate. The + // row has to show the same figure, or paying back the "you lent" it printed reopens the trip. + it('reads an amount in the display currency through the trip currency too', () => { + const liveUsd = (amount: number, from: string | null | undefined): number => { + const rates: Record = { USD: 1, EUR: 1 / 1.1398 } + const r = rates[(from || 'USD').toUpperCase()] + return r && r > 0 ? amount / r : amount + } + // 685.26 EUR, then at today's 1.1398 dollars to the euro. + expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).toBeCloseTo((801.76 / 1.17) * 1.1398, 6) + expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd)).not.toBeCloseTo(801.76, 1) + // Without a booked rate there is nothing to go through, and dollars stay dollars. + expect(convertBooked(801.76, 'USD', 1, 'EUR', liveUsd)).toBe(801.76) + }) +}) + +describe('bookedInTrip', () => { + it('gives the trip-currency amount a foreign row was booked at', () => { + expect(bookedInTrip(801.76, 'USD', 1.17, 'EUR')).toBeCloseTo(685.26, 2) + expect(bookedInTrip(12000, 'jpy', 170, 'eur')).toBeCloseTo(70.59, 2) + }) + + it('has nothing to give for a row in the trip currency or one that never froze a rate', () => { + expect(bookedInTrip(100, 'EUR', 1.17, 'EUR')).toBeNull() + expect(bookedInTrip(100, null, 1.17, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', 1, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', null, 'EUR')).toBeNull() + expect(bookedInTrip(100, 'USD', 0, 'EUR')).toBeNull() + }) +}) + +// #2525: the server freezes an entry rate from the trip currency's quote and converts the +// settlement back with it. Read through the display currency's own quote, a separately +// rounded figure, a same-day 12,345.67 USD bill on a euro trip came back as $12,346.05. +describe('useExchangeRates anchored on the trip currency (#2525)', () => { + beforeEach(() => { + clearExchangeRateCache() + }) + + const quotes = (): void => { + server.use(http.get(FX_URL, ({ request }) => { + const base = new URL(request.url).searchParams.get('base') + if (base === 'EUR') return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }, { quote: 'JPY', rate: 170 }]) + if (base === 'USD') return HttpResponse.json([{ quote: 'EUR', rate: 0.87732 }, { quote: 'JPY', rate: 149.1 }]) + return HttpResponse.error() + })) + } + + it('converts the trip currency with its own quote, so a same-day bill reads as typed', async () => { + quotes() + const { result } = renderHook(() => useExchangeRates('usd', 'eur')) + await waitFor(() => expect(result.current.convert(1, 'EUR')).toBe(1.1398)) + const shown = convertBooked(12345.67, 'USD', 1.1398, 'EUR', result.current.convert) + expect(Math.round(shown * 100)).toBe(1234567) + expect(Math.round(convertBooked(250, 'USD', 1.1398, 'EUR', result.current.convert) * 100)).toBe(25000) + // A foreign row that never froze a rate goes through the same quote as well. + expect(result.current.convert(170, 'JPY')).toBeCloseTo(1.1398, 10) + // The display currency's own rates stay what the hook hands out. + expect(result.current.rates).toEqual({ USD: 1, EUR: 0.87732, JPY: 149.1 }) + }) + + it('falls back to the display currency\'s quote while the trip\'s is missing', async () => { + server.use(http.get(FX_URL, ({ request }) => (new URL(request.url).searchParams.get('base') === 'USD' + ? HttpResponse.json([{ quote: 'EUR', rate: 0.8 }]) + : HttpResponse.error()))) + const { result } = renderHook(() => useExchangeRates('USD', 'EUR')) + await waitFor(() => expect(result.current.convert(8, 'EUR')).toBe(10)) + expect(result.current.convert(5, 'USD')).toBe(5) + }) + + it('fetches one set of rates when the trip is in the display currency', async () => { + let calls = 0 + server.use(http.get(FX_URL, () => { calls++; return HttpResponse.json([{ quote: 'USD', rate: 1.1398 }]) })) + const { result } = renderHook(() => useExchangeRates('EUR', 'EUR')) + await waitFor(() => expect(result.current.convert(1.1398, 'USD')).toBe(1)) + expect(calls).toBe(1) + }) +}) + +describe('crossRate', () => { + it('reads units of one currency per another from rates on any base', () => { + expect(crossRate({ EUR: 1, USD: 1.25, GBP: 0.8 }, 'USD', 'GBP')).toBe(1.5625) + expect(crossRate({ EUR: 1, USD: 1.25 }, 'USD', 'EUR')).toBe(1.25) + }) + + it('has nothing to say without both quotes', () => { + expect(crossRate(null, 'USD', 'EUR')).toBeNull() + expect(crossRate({ EUR: 1 }, 'USD', 'EUR')).toBeNull() + expect(crossRate({ EUR: 0, USD: 1 }, 'USD', 'EUR')).toBeNull() + }) +}) + +describe('tripAmountOf', () => { + // Display USD, trip EUR: one euro buys 1.25 dollars and 100 yen. + const live = (amount: number, from: string | null | undefined): number => + amount * ({ EUR: 1.25, USD: 1, JPY: 0.0125 } as Record)[(from || 'USD').toUpperCase()] + + it('is the booked amount for a row booked through the trip currency', () => { + expect(tripAmountOf(801.76, 'USD', 1.17, 'EUR', live)).toBeCloseTo(685.26, 2) + }) + + it('is the amount itself for a row in the trip currency', () => { + expect(tripAmountOf(100, null, null, 'eur', live)).toBe(100) + expect(tripAmountOf(100, 'EUR', 1, 'EUR', live)).toBe(100) + }) + + it('counts a row that never froze a rate at today\'s', () => { + expect(tripAmountOf(1000, 'JPY', 1, 'EUR', live)).toBeCloseTo(10, 10) + // Without rates the conversion is the identity, as it is on the server. + expect(tripAmountOf(1000, 'JPY', null, 'EUR', (a: number) => a)).toBe(1000) + }) +}) + +describe('convertedLine', () => { + it('shows the entered amount and the booked trip amount for a bill booked through the trip currency', () => { + expect(convertedLine(801.76, 'usd', 1.17, 'EUR', 'USD', 791.55)).toEqual({ + entered: { amount: 801.76, currency: 'USD' }, + into: { amount: 801.76 / 1.17, currency: 'EUR' }, + }) + expect(convertedLine(12000, 'JPY', 170, 'EUR', 'USD', 80.46)?.into).toEqual({ amount: 12000 / 170, currency: 'EUR' }) + }) + + it('has no line for a bill in the display currency that still reads as typed', () => { + expect(convertedLine(12345.67, 'USD', 1.1398, 'EUR', 'USD', 12345.670000000002)).toBeNull() + // Rounded to the display currency's own decimals. + expect(convertedLine(1500, 'JPY', 0.0125, 'EUR', 'JPY', 1500.2)).toBeNull() + }) + + it('has no line for an amount of 0 or a row that was never converted', () => { + expect(convertedLine(0, 'USD', 1.17, 'EUR', 'USD', 0)).toBeNull() + expect(convertedLine(0, 'JPY', 170, 'EUR', 'USD', 0)).toEqual({ entered: { amount: 0, currency: 'JPY' }, into: { amount: 0, currency: 'USD' } }) + expect(convertedLine(50, 'USD', 1, 'EUR', 'USD', 50)).toBeNull() + expect(convertedLine(50, null, null, 'USD', '', 50)).toBeNull() + }) + + it('goes straight to the display value for a row in the trip currency, or when the viewer reads the trip currency', () => { + expect(convertedLine(100, null, null, 'EUR', 'USD', 113.98)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 113.98, currency: 'USD' } }) + expect(convertedLine(801.76, 'USD', 1.17, 'EUR', 'EUR', 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } }) + }) }) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 2652312fa..2ea56af58 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { currencyDecimals } from '../utils/formatters' /** * Live FX rates for the Costs panel, used to convert every amount into the user's @@ -59,6 +60,13 @@ export async function fetchExchangeRates(base: string): Promise number, ): number { const trip = (tripCurrency || 'EUR').toUpperCase() + const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip) + if (inTrip != null) return convertLive(inTrip, trip) // A NULL currency means the trip's own, and then there was never anything to freeze. + return convertLive(amount, (rowCurrency || trip).toUpperCase()) +} + +/** + * The amount a row was booked at in the trip currency, when it was booked through one: a + * foreign currency with a frozen rate. Null for a row in the trip currency, and for one + * written before the freeze existed. A rate of exactly 1 is the column default, not a booked + * rate, and those rows still convert live, as they always did. + * + * The lists print it between the amount entered and the amount shown, so a row whose value + * moved with the rate shows where the move came from instead of a figure nobody typed. + */ +export function bookedInTrip( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, +): number | null { + const trip = (tripCurrency || 'EUR').toUpperCase() const cur = (rowCurrency || trip).toUpperCase() - if (cur === trip) return convertLive(amount, trip) - // A rate of exactly 1 is the column default, not a booked rate: rows written before the - // freeze existed carry it, and those still convert live, as they always did. - if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return convertLive(amount / frozenRate, trip) - return convertLive(amount, cur) + if (cur === trip) return null + if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return amount / frozenRate + return null +} + +/** + * What an amount of a row counts as in the trip currency, the figure the settlement nets: + * the booked amount for a row booked through the trip currency, the amount itself for one + * in it, and today's rate for a foreign row that never froze one, the way the server + * counts that row too. + */ +export function tripAmountOf( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, + convertLive: (amount: number, from: string | null | undefined) => number, +): number { + const trip = (tripCurrency || 'EUR').toUpperCase() + const inTrip = bookedInTrip(amount, rowCurrency, frozenRate, trip) + if (inTrip != null) return inTrip + const cur = (rowCurrency || trip).toUpperCase() + if (cur === trip) return amount + const perTrip = convertLive(1, trip) + return perTrip > 0 ? convertLive(amount, cur) / perTrip : amount +} + +/** An amount and the currency it is in. */ +export interface MoneyPart { amount: number; currency: string } + +/** + * The line under an amount the lists show converted (#2525): what was entered, then where + * it went. For a row booked through the trip currency that is the booked trip amount, + * "$801.76 -> 685,26 EUR", since the display value beside it already shows the last step. + * Any other foreign row goes straight to the display value, "100,00 EUR -> $113.98". + * + * Null when there is nothing to explain: a row in the display currency that still reads + * as typed, or an amount of 0. `shown` is the display value printed beside the line. + * Every list uses it, so the desktop and phone shells and the shared page can never + * explain the same row two ways. + */ +export function convertedLine( + amount: number, + rowCurrency: string | null | undefined, + frozenRate: number | null | undefined, + tripCurrency: string, + displayCurrency: string, + shown: number, +): { entered: MoneyPart; into: MoneyPart } | null { + const trip = (tripCurrency || 'EUR').toUpperCase() + const display = (displayCurrency || trip).toUpperCase() + const cur = (rowCurrency || trip).toUpperCase() + const entered = { amount, currency: cur } + const inTrip = display !== trip && amount !== 0 ? bookedInTrip(amount, cur, frozenRate, trip) : null + if (inTrip != null) { + const unit = 10 ** currencyDecimals(display) + if (cur === display && Math.round(shown * unit) === Math.round(amount * unit)) return null + return { entered, into: { amount: inTrip, currency: trip } } + } + return cur === display ? null : { entered, into: { amount: shown, currency: display } } } /** Test-only: the module-level cache outlives a vitest file's individual tests. */ @@ -89,29 +173,60 @@ export function clearExchangeRateCache(): void { } catch { /* ignore */ } } -export function useExchangeRates(base: string) { - const upper = (base || 'EUR').toUpperCase() - const [rates, setRates] = useState | null>(() => readCache(upper)?.rates ?? null) +/** Rates for one base, from the cache first and then the network; null for no base. */ +function useRatesFor(base: string | null): Record | null { + const [rates, setRates] = useState | null>(() => (base ? readCache(base)?.rates ?? null : null)) useEffect(() => { - const cached = readCache(upper) + if (!base) { setRates(null); return } + const cached = readCache(base) if (cached) setRates(cached.rates) if (cached && Date.now() - cached.ts < TTL_MS) return let cancelled = false - fetchExchangeRates(upper).then(r => { + fetchExchangeRates(base).then(r => { if (!cancelled && r) setRates(r) }) return () => { cancelled = true } - }, [upper]) + }, [base]) + + return rates +} + +/** + * Units of `to` per 1 `from`, from rates quoted against any base (each rate is units of + * that currency per 1 base). Null when either quote is missing. + */ +export function crossRate(rates: Record | null | undefined, to: string, from: string): number | null { + const rTo = rates?.[to] + const rFrom = rates?.[from] + return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : null +} + +/** + * `tripCurrency`, when given, anchors the conversion on the trip currency's own quote + * (#2525). That is the quote the server froze every entry rate from and converts the + * settlement with, so an amount booked through the trip currency comes back exactly as + * entered while the rate has not moved. The display currency's own quote is a separately + * rounded figure, not the exact inverse: through it a same-day 12,345.67 USD bill on a + * euro trip read as $12,346.05. Both quotes are cached like any other, so the anchor works + * offline too; the display quote only stands in while the trip's is missing. + */ +export function useExchangeRates(base: string, tripCurrency?: string | null) { + const upper = (base || 'EUR').toUpperCase() + const anchor = (tripCurrency || upper).toUpperCase() + const rates = useRatesFor(upper) + const anchorRates = useRatesFor(anchor === upper ? null : anchor) const convert = useCallback( (amount: number, from: string | null | undefined): number => { const f = (from || upper).toUpperCase() - if (f === upper || !rates) return amount - const r = rates[f] + if (f === upper) return amount + const viaAnchor = crossRate(anchorRates, upper, f) + if (viaAnchor != null) return amount * viaAnchor + const r = rates?.[f] return r && r > 0 ? amount / r : amount }, - [rates, upper], + [rates, anchorRates, upper], ) return { rates, convert } diff --git a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx index 0359c7e2f..fa2e86b4e 100644 --- a/client/src/mobile/screens/trip/sheets/MCostSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MCostSheet.tsx @@ -8,10 +8,10 @@ import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } import { useTranslation } from '../../../../i18n' import { useToast } from '../../../../components/shared/Toast' import { useTripStore } from '../../../../store/tripStore' -import { useExchangeRates } from '../../../../hooks/useExchangeRates' import { formatMoney, localizeAmountInput, amountToInputString } from '../../../../utils/formatters' import { openFile } from '../../../../utils/fileDownload' import { saveWithReceipts } from '../../../../components/Budget/receiptUploads' +import { splitShareLabel, useExpenseFx } from '../../../../components/Budget/expenseFx' import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories' import { localToday } from '../../../../components/Planner/today' @@ -58,8 +58,9 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const { t, locale } = useTranslation() const toast = useToast() const { addBudgetItem, updateBudgetItem, deleteBudgetItem } = useTripStore() - const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') + // A saved expense without a currency opens in the trip's own (#2525), as on desktop. + const { tripCurrency: tripCur, editingCurrency, preview } = useExpenseFx(base, editing) // Internal open flag so the exit animation still plays even though the parent // unmounts us on close. @@ -75,13 +76,13 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [catOpen, setCatOpen] = useState(false) const [note, setNote] = useState(() => readUserNote(editing)) - const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) + const [currency, setCurrency] = useState(editingCurrency) const [day, setDay] = useState(editing?.expense_date || localToday()) // Edit and prefill seeds are padded to the currency's decimals (#2175), same // as the desktop modal: a saved 4,90 must reopen as "4,90", not "4,9". A // prefill has no currency of its own and is read as `base`. const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : '' + if (editing) return editing.total_price ? amountToInputString(editing.total_price, editingCurrency) : '' if (prefill?.amount != null) return amountToInputString(prefill.amount, base) return '' }) @@ -151,6 +152,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, const ticketInfo = useMemo(() => calculateTicketShares(ticketItems), [ticketItems]) const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const fx = preview(totalNum, currency) const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) const each = participants.size > 0 ? totalNum / participants.size : 0 @@ -404,12 +406,18 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill, {/* CONVERSION HINT */} - {currency !== base && totalNum !== 0 && ( + {fx && (
{formatMoney(totalNum, currency, locale)} - ≈ - {formatMoney(convert(totalNum, currency), base, locale)} - · {t('costs.liveRate')} + {fx.inTrip != null && <> + → + {formatMoney(fx.inTrip, tripCur, locale)} + } + {fx.shown != null && <> + ≈ + {formatMoney(fx.shown, base, locale)} + · {t('costs.liveRate')} + }
)} @@ -646,12 +654,12 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
{splitMode === 'equally' ? ( - {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })} + {participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) })} ) : ( {customBalanced - ? t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) }) + ? t('costs.splitSummary', { count: participants.size, amount: splitShareLabel(each, currency, fx, participants.size, base, sym, locale) }) : `${sym(currency)}${splitSum.toFixed(2)} / ${sym(currency)}${totalNum.toFixed(2)}`} )} diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 20e44c98f..952b214cf 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -12,6 +12,7 @@ import { BookingCodeInput } from '../../../../components/shared/BookingCode' import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect' import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect' import { toLocationPicks } from '../../../../components/Planner/locationPicks' +import { importedPriceEntry } from '../../../../components/Planner/importedPrice' import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import PlFileAttach from './PlFileAttach' @@ -572,11 +573,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo // Imported booking → auto-create the linked cost from the parsed price // (only on create and only when a price is present). if (!res && prefill && isBudgetEnabled) { - const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} - const price = Number(pmeta.price) - if (Number.isFinite(price) && price > 0) { - payload.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } - } + const entry = importedPriceEntry(prefill.metadata, form.type) + if (entry) payload.create_budget_entry = entry } const saved = await saveTransport(payload) // Persist the traveler assignment once we have the reservation id (from the diff --git a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx index 3476410bd..1db224e33 100644 --- a/client/src/mobile/screens/trip/tabs/MCostsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MCostsTab.tsx @@ -1,4 +1,4 @@ -import { convertBooked } from '../../../../hooks/useExchangeRates' +import { convertBooked, convertedLine } from '../../../../hooks/useExchangeRates' import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { AlertCircle, ArrowDown, ArrowLeftRight, ArrowRight, ArrowUp, Check, ChevronDown, ChevronUp, @@ -14,7 +14,7 @@ import { downloadBlob, openFile } from '../../../../utils/fileDownload' import { budgetApi } from '../../../../api/client' import MCostSheet from '../sheets/MCostSheet' import { ReceiptPreviewModal } from '../../../../components/Budget/ReceiptPreviewModal' -import { finalBudgetFor, finalBudgetSources, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' +import { finalBudgetFor, finalBudgetSources, paidByUser, readUserNote, settlementDate } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories' import CustomSelect from '../../../../components/shared/CustomSelect' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' @@ -27,8 +27,8 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/ import { CountPill, TabScroller } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { - baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, currencyOf, - dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, memberShareOf, tint, + baseTotal, buildCostsCsv, categoryBreakdown, categoryFilterKeys, computeTotals, + dayFilterKeys, filterBudgetItems, filterSettlements, groupLedgerByDay, isUnfinished, lineOf, memberShareOf, tint, type CostsCtx, type CostsSegment, type CostsSettlement, type CostsSettlementResponse, } from './costsModel' import type { BudgetParticipantFinal } from '@trek/shared' @@ -55,8 +55,9 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { const displayCurrency = useSettingsStore(s => s.settings.default_currency) const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const tripCurrency = (trip?.currency || base).toUpperCase() - const { convert } = useExchangeRates(base) - const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, convert }), [me, tripCurrency, convert]) + // Anchored on the trip currency's quote, the one the server books with (#2525). + const { convert } = useExchangeRates(base, tripCurrency) + const ctx: CostsCtx = useMemo(() => ({ me, tripCurrency, displayCurrency: base, convert }), [me, tripCurrency, base, convert]) const [settlement, setSettlement] = useState(null) // A failed settlement read leaves `settlement` null, and the final budget would @@ -339,7 +340,7 @@ export default function MCostsTab({ planner, shell }: MTabScreenProps) { : } {open && ( - + )}
) @@ -622,8 +623,8 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT }) { const meta = catMeta(item.category) const Icon = meta.Icon - const cur = currencyOf(item, ctx) const total = baseTotal(item, ctx) + const line = lineOf(item.total_price || 0, item, ctx, total) const unfinished = isUnfinished(item, ctx) const borderColor = tint(meta.color, 0.55) const members = item.members || [] @@ -670,9 +671,9 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT )} - {cur !== base && ( + {line && (
- {formatMoney(item.total_price, cur, locale)} {'→'} {formatMoney(total, base, locale)} + {formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
)} {members.length > 0 && ( @@ -756,7 +757,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE }) { const cur = (settlement.currency || base).toUpperCase() // At the rate it was settled at, not today's (#1445), matching the desktop ledger. - const amount = convertBooked(settlement.amount, settlement.currency, settlement.exchange_rate, ctx.tripCurrency, ctx.convert) + // A transfer without a currency was entered in the display currency, not the trip's. + const amount = convertBooked(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, ctx.convert) + const line = convertedLine(settlement.amount, cur, settlement.exchange_rate, ctx.tripCurrency, base, amount) return (
@@ -767,9 +770,9 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE
{t('costs.payment')}
{personName(settlement.from_user_id)} → {personName(settlement.to_user_id)}
- {cur !== base && ( + {line && (
- {formatMoney(settlement.amount, cur, locale)} {'→'} {formatMoney(amount, base, locale)} + {formatMoney(line.entered.amount, line.entered.currency, locale)} {'→'} {formatMoney(line.into.amount, line.into.currency, locale)}
)}
@@ -799,9 +802,10 @@ function PaymentRow({ settlement, ctx, base, locale, t, personName, canEdit, onE * with the balances; each line is signed by what it does to the final, so the * column reads as a subtraction and the rows add up to the line above them. */ -function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { +function FinalBudgetBreakdown({ row, items, ctx, base, locale, t, personName }: { row: BudgetParticipantFinal items: BudgetItem[] + ctx: CostsCtx base: string locale: string t: TFn @@ -810,6 +814,12 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { const signed = (v: number) => (v < 0 ? '−' : '+') + formatMoney(Math.abs(v), base, locale) const { fronted, moved, outstanding } = finalBudgetSources(row, items) const transfer = (fromId: number, toId: number) => `${personName(fromId)} → ${personName(toId)}` + // Beside a converted row's name, what was entered, as the list above names it (#2525). + const nameOf = (itemId: number, name: string, shown: number) => { + const e = items.find(i => i.id === itemId) + const entered = e ? lineOf(paidByUser(e, row.user_id), e, ctx, shown)?.entered : null + return entered ? `${name} · ${formatMoney(entered.amount, entered.currency, locale)}` : name + } const line = (key: string, label: string, value: string) => (
{label} @@ -828,7 +838,7 @@ function FinalBudgetBreakdown({ row, items, base, locale, t, personName }: { {line('expenses', t('costs.finalExpenses'), signed(row.expenses))} {line('reimbursed', t('costs.finalReimbursed'), signed(-row.reimbursed))} {line('pending', t('costs.finalPending'), signed(-row.pending))} - {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, r.name, signed(r.amount))))} + {section(t('costs.finalExpenses'), fronted.map(r => line(`e${r.item_id}`, nameOf(r.item_id, r.name, r.amount), signed(r.amount))))} {section(t('costs.finalReimbursed'), moved.map(r => line(`s${r.settlement_id}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))} {section(t('costs.finalPending'), outstanding.map((r, i) => line(`f${i}`, transfer(r.from_user_id, r.to_user_id), signed(-r.amount))))}
diff --git a/client/src/mobile/screens/trip/tabs/costsModel.ts b/client/src/mobile/screens/trip/tabs/costsModel.ts index aa8c67962..4b6707a4c 100644 --- a/client/src/mobile/screens/trip/tabs/costsModel.ts +++ b/client/src/mobile/screens/trip/tabs/costsModel.ts @@ -1,7 +1,7 @@ import type { BudgetParticipantFinal, CostCategory } from '@trek/shared' import { paidByUser, readUserNote, settlementDate, splitEqualShares } from '../../../../components/Budget/CostsPanel.helpers' import { catMeta, COST_CATEGORY_LIST } from '../../../../components/Budget/costsCategories' -import { convertBooked } from '../../../../hooks/useExchangeRates' +import { convertBooked, convertedLine, tripAmountOf } from '../../../../hooks/useExchangeRates' import { currencyDecimals } from '../../../../utils/formatters' import type { BudgetItem } from '../../../../types' @@ -19,6 +19,8 @@ export interface CostsCtx { me: number /** The trip's own currency — what a NULL `budget_items.currency` means. */ tripCurrency: string + /** The currency everything is shown in, the base `convert` converts to. */ + displayCurrency: string convert: (amount: number, currency: string | null | undefined) => number } @@ -32,6 +34,15 @@ export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number { return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert) } +/** + * The line under an amount of this expense the list shows converted, what was entered and + * where it went (#2525); null when there is nothing to explain. `shown` is the display + * value printed beside it. + */ +export function lineOf(amount: number, e: BudgetItem, ctx: CostsCtx, shown: number) { + return convertedLine(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.displayCurrency, shown) +} + /** Expense total converted to the display/base currency, at its booked rate. */ export function baseTotal(e: BudgetItem, ctx: CostsCtx): number { return booked(e.total_price || 0, e, ctx) @@ -315,12 +326,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil } } - const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', `Amount (${opts.base})`, 'Note'] + // Read in another currency than the trip's, what each row counts as in the trip currency + // too, the figure every sum is built from (#2525). Same columns as the desktop export. + const trip = opts.ctx.tripCurrency.toUpperCase() + const tripCol = trip !== opts.base.toUpperCase() + const header = ['Date', 'Name', 'Category', 'Amount', 'Currency', ...(tripCol ? [`Amount (${trip})`] : []), `Amount (${opts.base})`, 'Note'] const rows = [header.join(sep)] const sorted = items.slice().sort((a, b) => (a.expense_date || '').localeCompare(b.expense_date || '')) for (const e of sorted) { const cur = currencyOf(e, opts.ctx) const note = readUserNote(e) + const inTrip = tripAmountOf(e.total_price || 0, e.currency, e.exchange_rate, trip, opts.ctx.convert) rows.push( [ esc(fmtDate(e.expense_date || '')), @@ -328,6 +344,7 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil esc(opts.t(catMeta(e.category).labelKey)), (e.total_price || 0).toFixed(currencyDecimals(cur)), cur, + ...(tripCol ? [inTrip.toFixed(currencyDecimals(trip))] : []), baseTotal(e, opts.ctx).toFixed(currencyDecimals(opts.base)), esc(note), ].join(sep), diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 6a67e073d..a18dc9877 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -597,6 +597,112 @@ describe('SharedTripPage', () => { }); }); + describe('FE-PAGE-SHARED-043: a foreign expense reads at the rate it was booked at, as in Costs (#2525)', () => { + it('shows the frozen-rate value in whole cents, not today\'s rate', async () => { + // CHF so no earlier test has cached rates for this base. Today 1 CHF buys 1.1395 + // USD; the expense froze 1.17 when it was entered. Costs reads 801.76 / 1.17 = + // 685.26 CHF. Today's rate alone gives 703.61, printed as "703.607". + server.use( + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'USD', rate: 1.1395 }])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'booked-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' }, + baseCurrency: 'CHF', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 }, + { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('booked-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument()); + // Row and its category both read the booked value; the trip total adds the + // 100 CHF tram pass to it. + await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2)); + expect(screen.getByText('785.26 CHF')).toBeInTheDocument(); + expect(screen.queryByText(/703\.6/)).toBeNull(); + // What was entered stays beside the converted row, as the Costs list shows it. + expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); + }); + + it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => { + // ZAR trip read in USD (ZAR so no earlier test has cached its rates). The bill froze + // 0.05911 USD per rand, today's ZAR quote. The USD quote, 16.918 rand per dollar, is + // rounded on its own and is not its inverse. + server.use( + http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => { + const base = new URL(request.url).searchParams.get('base'); + return HttpResponse.json(base === 'ZAR' ? [{ quote: 'USD', rate: 0.05911 }] : [{ quote: 'ZAR', rate: 16.918 }]); + }), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'same-day-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'ZAR' }, + baseCurrency: 'USD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Villa', total_price: 12345.67, category: 'Accommodation', currency: 'USD', exchange_rate: 0.05911 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('same-day-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3)); + expect(screen.queryByText(/^· /)).toBeNull(); + }); + + it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => { + // The owner reads in USD on a CHF trip. The USD bill was booked at 685.26 CHF, + // which today is 780.86 USD, the figure Costs and its balances use; the CHF one + // converts at today's rate (100 CHF = 113.95 USD). + server.use( + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'CHF', rate: 1 / 1.1395 }])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'display-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'CHF' }, + baseCurrency: 'USD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [ + { id: 1, name: 'Aparthotel Silver', total_price: 801.76, category: 'Accommodation', currency: 'USD', exchange_rate: 1.17 }, + { id: 2, name: 'Tram pass', total_price: 100, category: 'Transport', currency: null, exchange_rate: 1 }, + ], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('display-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /costs/i })); + + await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2)); + expect(screen.getAllByText('780.86 USD')).toHaveLength(2); + expect(screen.getByText('894.81 USD')).toBeInTheDocument(); + expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); + expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument(); + }); + }); + // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of // the page: header variants, the permission-driven tab strip, and every item kind // the day timeline, bookings, packing, costs and chat sections can produce. diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 26b1c569b..2080dcab2 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -27,13 +27,14 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'; import PublicLanguagePicker from '../components/shared/PublicLanguagePicker'; import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults'; import VectorBasemap from '../components/Map/VectorBasemap'; +import { convertBooked, convertedLine } from '../hooks/useExchangeRates'; import { useTranslation } from '../i18n'; import { avatarSrc } from '../utils/avatarSrc'; import { safeHexColor } from '../utils/safeColor'; import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge'; import { isDayInAccommodationRange } from '../utils/dayOrder'; import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'; -import { splitReservationDateTime } from '../utils/formatters'; +import { currencyDecimals, splitReservationDateTime } from '../utils/formatters'; import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; @@ -981,16 +982,31 @@ export default function SharedTripPage() { {activeTab === 'budget' && (budget || []).length > 0 && (() => { - // Pre-rework rows store currency = NULL ("the trip's own currency"); convert - // each expense into the owner's display base via live FX, mirroring CostsPanel. - const curOf = (i: any) => i.currency || trip.currency || base; + // Pre-rework rows store currency = NULL ("the trip's own currency"). Each + // expense is read the way CostsPanel reads it (#2525): at the rate frozen when + // it was entered, into the trip currency, then into the owner's display base. + // Reading it at today's rate alone made the shared page disagree with the + // trip's own Costs tab. + type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null }; + const tripCurrency = String(trip.currency || base).toUpperCase(); + const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0; + const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert); + // Whole cents: a converted amount otherwise printed a third decimal. + const money = (v: number) => + v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + // What was entered, beside a row shown converted, as the Costs list prints it. + const enteredOf = (i: Expense): string | null => { + const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered; + if (!entered) return null; + const d = currencyDecimals(entered.currency); + return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`; + }; const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g; }, {}); - const sumIn = (items: any[]) => - items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0); + const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0); const total = sumIn(budget || []); return (
@@ -1015,7 +1031,7 @@ export default function SharedTripPage() { {t('shared.totalBudget')}
- {total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base} + {money(total)} {base}
{/* By category */} @@ -1045,7 +1061,7 @@ export default function SharedTripPage() { className="text-[#6b7280]" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }} > - {sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base} + {money(sumIn(items))} {base}
{items.map((item: any) => ( @@ -1061,13 +1077,16 @@ export default function SharedTripPage() { > {item.name} + {item.total_price && enteredOf(item) ? ( + · {enteredOf(item)} + ) : null} {item.total_price - ? `${convert(Number.parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` + ? `${money(valueOf(item))} ${base}` : '—'} diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index 9eb76cab2..975568d4f 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -77,7 +77,8 @@ export function useSharedTrip() { // Convert every expense into it via live FX, mirroring CostsPanel — a public // viewer has no settings store, so the base comes from the payload (#1361). const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase() - const { convert } = useExchangeRates(base) + // Anchored on the trip currency's quote, as the Costs tab converts (#2525). + const { convert } = useExchangeRates(base, data?.trip?.currency) return { data, diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index aba53cdbd..e5699a028 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -4,10 +4,10 @@ import type { TripMember } from '../../../../src/components/Budget/BudgetPanelMe import { clearExchangeRateCache } from '../../../../src/hooks/useExchangeRates' import { useTripStore, type TripStoreState } from '../../../../src/store/tripStore' import type { BudgetItem, BudgetItemMember } from '../../../../src/types' -import { buildBudgetItem } from '../../../helpers/factories' +import { buildBudgetItem, buildTrip } from '../../../helpers/factories' import { localToday } from '../../../../src/components/Planner/today' import { resetAllStores } from '../../../helpers/store' -import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' // FE-MOB-COSTSH-001 to FE-MOB-COSTSH-030 // The sheet reads its copy from useTranslation(), so assertions are English. @@ -648,6 +648,66 @@ describe('MCostSheet', () => { }))) }) + it('FE-MOB-COSTSH-035: an expense saved without a currency reopens in the trip currency (#2525)', async () => { + // No currency on the row means the trip's own. Seeding the display currency + // instead labelled 100 euro as 100 dollars, and a plain save stored it so. + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.8 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 12, name: 'Tram pass', category: 'transport', currency: null, total_price: 100, + members: [member(1, null)], + payers: [{ user_id: 1, amount: 100 }], + }) + renderSheet({ base: 'USD', editing }) + + expect(totalField()).toHaveValue('100,00') + expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'USD $' })).toBeNull() + + fireEvent.click(saveBtn()) + await waitFor(() => expect(updateBudgetItem).toHaveBeenCalledWith(1, 12, expect.objectContaining({ + total_price: 100, + currency: 'EUR', + }))) + }) + + it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => { + // A euro trip read in dollars, the bill entered in dollars at 1.17 to the euro. + // Same currency as the list, so the sheet said nothing, while the list showed the + // booked euros at today's rate beside the 801.76 typed here. + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 13, name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, total_price: 801.76, + members: [member(1, null), member(2, null)], + payers: [{ user_id: 1, amount: 801.76 }], + }) + renderSheet({ base: 'USD', editing }) + + const hint = screen.getByText(/live rate/).parentElement as HTMLElement + expect(within(hint).getByText('$801.76')).toBeInTheDocument() + expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument() + expect(within(hint).getByText('$791.55')).toBeInTheDocument() + // Each share next to what it counts as, the figure the row's "you lent" is made of. + expect(screen.getByText(/Split 2 ways · \$400\.88 → \$395\.77 each/)).toBeInTheDocument() + }) + + it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => { + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() })) + useTripStore.setState({ trip: buildTrip({ id: 1, currency: 'EUR' }) } as unknown as Partial) + const editing = buildBudgetItem({ + id: 14, name: 'Villa', category: 'accommodation', currency: 'USD', exchange_rate: 1.1398, total_price: 12345.67, + members: [member(1, null), member(2, null)], + payers: [{ user_id: 1, amount: 12345.67 }], + }) + renderSheet({ base: 'USD', editing }) + + expect(screen.queryByText(/live rate/)).toBeNull() + expect(screen.queryByText(/→/)).toBeNull() + }) + it('FE-MOB-COSTSH-030: a currency without a known symbol falls back to its code', () => { localStorage.setItem('trek_fx_XTS', JSON.stringify({ rates: { XTS: 1 }, ts: Date.now() })) renderSheet({ base: 'XTS' }) diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 6c6d11d3d..4b95a4b11 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -639,7 +639,8 @@ describe('MCostsTab', () => { expect(mine).toHaveAttribute('aria-expanded', 'false') expect(within(card).getAllByText('+$58.00')).toHaveLength(2) expect(within(card).queryByText(/60\.00/)).not.toBeInTheDocument() - expect(within(card).getByText('Museum')).toBeInTheDocument() + // Named by what Bob entered, as the list names it (#2525), never re-converted. + expect(within(card).getByText('Museum · £30.00')).toBeInTheDocument() fireEvent.click(bob) expect(bob).toHaveAttribute('aria-expanded', 'false') @@ -725,6 +726,74 @@ describe('MCostsTab', () => { expect(update).toHaveBeenCalledWith(7, 505, { from_user_id: 1, to_user_id: 2, amount: 10, currency: 'GBP', settled_at: '2026-04-28' }) }) + it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => { + // A euro trip read in dollars, the bill entered in dollars at a frozen 1.17 to the + // euro while today the euro buys 1.1551. It is 685.26 EUR of trip money, $791.55 + // today, and that is what settle-up offers too; the row now says where the + // 801.76 that was entered went instead of hiding it. Converted with the euro's own + // quote, not the dollar's separately rounded one. + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.865706 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1551 }, ts: Date.now() })) + const hotel = { + id: 21, trip_id: 7, name: 'Aparthotel Silver', category: 'accommodation', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + expense_date: '2026-05-01', note: null, + members: [ + { user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null }, + { user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null }, + ], + payers: [{ user_id: 1, amount: 801.76, username: 'Me' }], + } as unknown as BudgetItem + serveSettlement({ + ...SETTLEMENT, + settlements: [ + { id: 601, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, settled_at: '2026-05-02', created_at: '2026-05-02T09:00:00Z' }, + // A transfer from before they carried a currency was entered in the display currency. + { id: 602, from_user_id: 3, to_user_id: 1, amount: 30, currency: null, settled_at: '2026-05-03', created_at: '2026-05-03T09:00:00Z' }, + ], + }) + await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [hotel] })) + + const card = cardOf('Aparthotel Silver') + expect(within(card).getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument() + expect(within(card).getByText('$791.55')).toBeInTheDocument() + // Each share is half of the booked euros, the figure the balances net. + expect(within(card).getAllByText('$395.77')).toHaveLength(2) + expect(within(card).queryByText('$400.88')).toBeNull() + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$791.55') + expect(screen.getByText('costs.spent:$791.55')).toBeInTheDocument() + + expect(screen.getAllByText('costs.payment')).toHaveLength(2) + const paid = up(screen.getByText('Ada → costs.you'), 2) + expect(within(paid).getByText(/^\$400\.88 → 342,63\s€$/)).toBeInTheDocument() + expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument() + expect(screen.queryByText('$34.65')).toBeNull() + }) + + it('FE-MOB-COSTT-046: a bill booked at today\'s rate reads exactly as typed (#2525)', async () => { + seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }) + localStorage.setItem('trek_fx_USD', JSON.stringify({ rates: { USD: 1, EUR: 0.87732 }, ts: Date.now() })) + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 1.1398 }, ts: Date.now() })) + const villa = { + id: 22, trip_id: 7, name: 'Villa', category: 'accommodation', total_price: 12345.67, currency: 'USD', exchange_rate: 1.1398, + expense_date: '2026-05-01', note: null, + members: [ + { user_id: 1, paid: 1, username: 'Me', amount: null, avatar_url: null }, + { user_id: 2, paid: 0, username: 'Ada', amount: null, avatar_url: null }, + ], + payers: [{ user_id: 1, amount: 12345.67, username: 'Me' }], + } as unknown as BudgetItem + serveSettlement(SETTLEMENT) + await renderTab(planner({ trip: { ...TRIP, currency: 'EUR' } as Trip, budgetItems: [villa] })) + + const card = cardOf('Villa') + // Through the dollar's own quote it read $12,346.05. + expect(within(card).getByText('$12,345.67')).toBeInTheDocument() + expect(within(card).queryByText(/^\$12,345\.67 →/)).toBeNull() + expect(screen.getByText('costs.spent:$12,345.67')).toBeInTheDocument() + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$12,345.67') + }) + it('FE-MOB-COSTT-044: a payment cannot be saved without a day', async () => { // Cleared, the server would store NULL and the ledger would quietly fall // back to the day the payment was recorded on. diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index 80bdb9e1d..22fee84ed 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -826,6 +826,18 @@ describe('MTransportFormSheet', () => { expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car') }) + it('FE-MOB-TRFRM-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => { + const handleSaveTransport = makeSave() + const planner = makePlanner({ + transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] }, + handleSaveTransport, + }) + renderSheet(planner) + await submit() + // Sent without it, the server stored 117 in the trip currency. + expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' }) + }) + it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => { seedStore(useAddonStore, { addons: [] }) const handleSaveTransport = makeSave() diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 9a239cd1d..9d5db7772 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -12,6 +12,7 @@ import { groupByDay, groupLedgerByDay, isUnfinished, + lineOf, memberShareOf, myPaidOf, myShareOf, @@ -32,6 +33,7 @@ const ctx: CostsCtx = { me: 1, // lower case on purpose — currencyOf has to normalise it tripCurrency: 'eur', + displayCurrency: 'EUR', convert: (amount, currency) => amount * (RATES[(currency || 'EUR').toUpperCase()] ?? 1), }; @@ -306,6 +308,86 @@ describe('costsModel — list filters and grouping', () => { }); }); +describe('costsModel: a bill entered in the display currency (#2525)', () => { + // A euro trip read in dollars, converted with the euro's own quote the way + // useExchangeRates(base, tripCurrency) converts: one euro buys 1.1551 dollars today. + const usdCtx: CostsCtx = { + me: 1, + tripCurrency: 'EUR', + displayCurrency: 'usd', + convert: (amount, currency) => { + const perEuro = ({ USD: 1.1551, EUR: 1, SEK: 10 } as Record)[(currency || 'USD').toUpperCase()] ?? 1 + return (amount / perEuro) * 1.1551 + }, + }; + const hotel = (rate = 1.17) => expense({ + total_price: 801.76, currency: 'USD', exchange_rate: rate, + payers: [payer(1, 801.76)], members: [member(1), member(2)], + }); + + it('FE-MOB-CMOD-038: a bill booked at a rate that has moved since counts as its euros at today\'s rate', () => { + // 685.26 EUR, which the balances net and settle-up offers; the row, the payer's + // figure and the share read the same, or a payback of the row's half reopens the trip. + expect(baseTotal(hotel(), usdCtx)).toBeCloseTo(791.55, 2); + expect(myPaidOf(hotel(), usdCtx)).toBeCloseTo(791.55, 2); + expect(myShareOf(hotel(), usdCtx)).toBeCloseTo(395.77, 2); + // Under it, what was entered and the euros it was booked at, no third step. + const line = lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))!; + expect(line.entered).toEqual({ amount: 801.76, currency: 'USD' }); + expect(line.into.currency).toBe('EUR'); + expect(line.into.amount).toBeCloseTo(685.26, 2); + }); + + it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => { + const big = expense({ total_price: 12345.67, currency: 'USD', exchange_rate: 1.1551, payers: [payer(1, 12345.67)], members: [member(1), member(2)] }); + const unpaid = expense({ total_price: 250, currency: 'USD', exchange_rate: 1.1551 }); + expect(Math.round(baseTotal(big, usdCtx) * 100)).toBe(1234567); + expect(Math.round(baseTotal(unpaid, usdCtx) * 100)).toBe(25000); + expect(lineOf(12345.67, big, usdCtx, baseTotal(big, usdCtx))).toBeNull(); + const t = computeTotals([big, unpaid], [], usdCtx); + expect(Math.round(t.totalSpend * 100)).toBe(1259567); + expect(Math.round(t.outstanding * 100)).toBe(25000); + }); + + it('FE-MOB-CMOD-040: other currencies go through the trip currency at their booked rate', () => { + // 1000 SEK booked at 10 per euro is 100 euro, and 100 euro is 115.51 dollars today. + const sek = expense({ total_price: 1000, currency: 'SEK', exchange_rate: 10 }) + expect(baseTotal(sek, usdCtx)).toBeCloseTo(115.51, 2); + expect(lineOf(1000, sek, usdCtx, baseTotal(sek, usdCtx))).toEqual({ entered: { amount: 1000, currency: 'SEK' }, into: { amount: 100, currency: 'EUR' } }); + // A row in the trip currency goes straight to the dollars. + const eur = expense({ total_price: 100, currency: null }) + expect(baseTotal(eur, usdCtx)).toBeCloseTo(115.51, 2); + expect(lineOf(100, eur, usdCtx, 115.51)).toEqual({ entered: { amount: 100, currency: 'EUR' }, into: { amount: 115.51, currency: 'USD' } }); + }); + + it('FE-MOB-CMOD-041: there is nothing to explain when nothing was converted', () => { + // An amount of 0, a row written before the freeze, and any row when the viewer + // reads the trip currency itself. + const zero = expense({ total_price: 0, currency: 'USD', exchange_rate: 1.17 }); + expect(lineOf(0, zero, usdCtx, 0)).toBeNull(); + const legacy = expense({ total_price: 50, currency: 'USD', exchange_rate: 1 }); + expect(baseTotal(legacy, usdCtx)).toBe(50); + expect(lineOf(50, legacy, usdCtx, 50)).toBeNull(); + const inEur = { ...usdCtx, displayCurrency: 'EUR', convert: (a: number) => a }; + expect(lineOf(801.76, hotel(), inEur, 685.26)).toEqual({ entered: { amount: 801.76, currency: 'USD' }, into: { amount: 685.26, currency: 'EUR' } }); + }); + + it('FE-MOB-CMOD-042: the CSV adds what each row counts as in the trip currency', () => { + const t = ((key: string) => key) as unknown as Parameters[1]['t']; + const items = [ + hotel(), + expense({ name: 'Old taxi', total_price: 115.51, currency: 'USD', exchange_rate: 1, expense_date: '2026-07-20' }), + ].map((e, i) => ({ ...e, name: e.name || 'Hotel', expense_date: e.expense_date || '2026-07-19', id: i + 1 })); + const inUsd = buildCostsCsv(items, { base: 'USD', ctx: usdCtx, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n'); + expect(inUsd[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note'); + expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/); + // Never booked, so counted at today's rate, as the server counts it. + expect(inUsd[2]).toMatch(/;115\.51;USD;100\.00;115\.51;$/); + const inEur = buildCostsCsv(items, { base: 'EUR', ctx: { ...usdCtx, displayCurrency: 'EUR' }, locale: 'en-US', tripTitle: 'Trip', t }).content.split('\r\n'); + expect(inEur[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Note'); + }); +}); + describe('costsModel — ledger grouping (expenses + settlement payments)', () => { it('FE-MOB-CMOD-032: merges expenses and payments into shared day groups, newest first, no-date last', () => { const e = expense({ id: 50, expense_date: '2026-07-02' }); diff --git a/server/src/nest/budget/budget.controller.ts b/server/src/nest/budget/budget.controller.ts index e575bd441..c2dab9aca 100644 --- a/server/src/nest/budget/budget.controller.ts +++ b/server/src/nest/budget/budget.controller.ts @@ -62,8 +62,8 @@ export class BudgetController { } @Get('summary/per-person') - perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { - return { summary: this.budget.perPersonSummary(tripId) }; + async perPerson(@CurrentUser() user: User, @Param('tripId') tripId: string) { + return { summary: await this.budget.perPersonSummary(tripId) }; } @Get('settlement') diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 77df7e97b..63d5b42b6 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -402,10 +402,9 @@ export class BudgetMcp { async getSettlementSummary({ tripId, base }: { tripId: number; base?: string }, ctx: McpContext) { if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess(); const trip = this.db.get<{ currency?: string }>('SELECT currency FROM trips WHERE id = ?', tripId); - const tripCurrency = trip?.currency || 'EUR'; - const effectiveBase = (base || tripCurrency).toUpperCase(); - const rates = await this.exchangeRates.getRates(effectiveBase); - const summary = this.budget.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + // The same call the REST route makes, so both convert with the trip currency's own + // quote and an amount entered in `base` reads back to the cent (#2525). + const summary = await this.budget.settlement(tripId, base, trip?.currency || 'EUR'); return ok({ summary }); } @@ -556,7 +555,8 @@ export class BudgetMcp { }], }; } - const summary = this.budget.getPerPersonSummary(id); + // In the trip currency, each share at the rate its expense was booked at (#2525). + const summary = await this.budget.perPersonSummary(id); return { contents: [{ uri: uri.href, diff --git a/server/src/nest/budget/budget.service.ts b/server/src/nest/budget/budget.service.ts index b2a6272f1..f66689234 100644 --- a/server/src/nest/budget/budget.service.ts +++ b/server/src/nest/budget/budget.service.ts @@ -88,6 +88,44 @@ function allocateDisplayCents(cents: number[], factor: number, total = Math.roun return out; } +/** + * An amount of a row, in the row's own currency, in the trip currency. The rate frozen + * when the row was entered wins (#1335), so a booked expense keeps the value it was + * booked at. A row written before the freeze existed (rate 1 or none) converts through + * today's `rates`, units of each currency per 1 of their base, and without them stays + * as it is. Pre-rework rows store currency = NULL, which means the trip's own. + * + * The settlement nets with it and every trip total adds up with it, so the two can never + * read the same rows differently. + */ +function toTripAmount( + amount: number, + itemCurrency: string | null | undefined, + itemRate: number | null | undefined, + tripCurrency: string, + rates: Record | null, +): number { + const cur = (itemCurrency || tripCurrency).toUpperCase(); + if (cur === tripCurrency) return amount; + if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; + if (!rates) return amount; + const rCur = rates[cur]; + const rTrip = rates[tripCurrency]; + if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; + return amount; +} + +/** + * Units of `to` per 1 `from`, from a set of rates quoted against any base (each rate is + * units of that currency per 1 base). 1 when either quote is missing, which leaves an + * amount as it is, the way every conversion here degrades without rates. + */ +function quoteRatio(rates: Record | null, to: string, from: string): number { + const rTo = rates?.[to]; + const rFrom = rates?.[from]; + return rTo && rTo > 0 && rFrom && rFrom > 0 ? rTo / rFrom : 1; +} + /** * Budget domain service — owns the budget SQL (moved from the legacy * services/budgetService.ts: identical statements, the `||` falsy-coercion @@ -541,7 +579,7 @@ export class BudgetService { linkBudgetItemToReservation( tripId: string | number, reservationId: number, - data: { name: string; category?: string; total_price: number }, + data: { name: string; category?: string; total_price: number; currency?: string | null; exchange_rate?: number }, ) { // createBudgetItem accepts reservation_id directly — the legacy separate // UPDATE after the insert was redundant (and non-atomic). @@ -814,20 +852,101 @@ export class BudgetService { // Per-person summary // ------------------------------------------------------------------------- - getPerPersonSummary(tripId: string | number) { - const summary = this.db.all<{ user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }>(` - SELECT bm.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar, - SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned, - SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid, - COUNT(bi.id) as items_count + /** + * What each member is down for across the trip (`total_assigned`), and how much of + * that is marked paid (`total_paid`), in the trip currency. + * + * Each share is converted to trip cents the way the settlement converts it, at the + * rate frozen when the expense was entered, and an equal split is a largest-remainder + * split of those cents (#2525). The SQL this replaces summed raw amounts across + * currencies and divided them in floats, so a bill of 801.76 USD counted as 801.76 of + * the trip's euros and a third of 100 came out as 33.333… + */ + getPerPersonSummary(tripId: string | number, rates: Record | null = null) { + const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId); + const tripCurrency = (trip?.currency || 'EUR').toUpperCase(); + const items = this.db.all<{ id: number; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT id, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ?', tripId, + ); + const members = this.db.all<{ budget_item_id: number; user_id: number; amount: number | null; paid: number | null; username: string; avatar: string | null }>(` + SELECT bm.budget_item_id, bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN budget_items bi ON bm.budget_item_id = bi.id JOIN users u ON bm.user_id = u.id WHERE bi.trip_id = ? - GROUP BY bm.user_id `, tripId); + const toTripCents = (amount: number, item: { currency: string | null; exchange_rate: number | null }) => + Math.round(toTripAmount(amount, item.currency, item.exchange_rate, tripCurrency, rates) * 100); - return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })); + const people = new Map(); + for (const item of items) { + const own = members.filter(m => m.budget_item_id === item.id); + if (own.length === 0) continue; + // A member with an amount of their own owes exactly that; the rest split the + // total evenly, as the query this replaces did. + const equal = this.splitEqualShares(toTripCents(item.total_price || 0, item), own, item.id); + for (const m of own) { + const share = m.amount !== null && m.amount !== undefined ? toTripCents(m.amount, item) : (equal[m.user_id] || 0); + let p = people.get(m.user_id); + if (!p) { + p = { user_id: m.user_id, username: m.username, avatar: m.avatar, assigned: 0, paid: 0, items_count: 0 }; + people.set(m.user_id, p); + } + p.assigned += share; + if (m.paid === 1) p.paid += share; + p.items_count += 1; + } + } + + return [...people.values()] + .sort((a, b) => a.user_id - b.user_id) + .map(p => ({ + user_id: p.user_id, username: p.username, avatar: p.avatar, + total_assigned: p.assigned / 100, total_paid: p.paid / 100, items_count: p.items_count, + currency: tripCurrency, + avatar_url: avatarUrl(p), + })); + } + + /** + * What the trip's expenses add up to in the trip currency, overall and per category + * (#2525). Every row is converted once, the way the settlement converts it, and + * rounded to a whole cent before anything is added. Summing total_price as stored adds + * dollars to euros and labels the result with the trip currency. + */ + tripTotals(tripId: string | number, tripCurrency: string, rates: Record | null = null): { total: number; byCategory: Record } { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const rows = this.db.all<{ category: string | null; total_price: number | null; currency: string | null; exchange_rate: number | null }>( + 'SELECT category, total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? ORDER BY id', tripId, + ); + let total = 0; + const byCategory: Record = {}; + for (const r of rows) { + const cents = Math.round(toTripAmount(r.total_price || 0, r.currency, r.exchange_rate, trip, rates) * 100); + total += cents; + const cat = r.category || ''; + byCategory[cat] = (byCategory[cat] || 0) + cents; + } + return { + total: total / 100, + byCategory: Object.fromEntries(Object.entries(byCategory).map(([cat, cents]) => [cat, cents / 100])), + }; + } + + /** + * Today's rates against the trip currency, for the totals above. Fetched only when a + * row in another currency never froze a rate of its own: every other row converts + * without them, so a trip whose rows were all booked never waits on the network. + */ + async ratesForTripTotals(tripId: string | number, tripCurrency: string): Promise | null> { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const unbooked = this.db.get(` + SELECT 1 FROM budget_items + WHERE trip_id = ? AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? + AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) + LIMIT 1 + `, tripId, trip); + return unbooked ? this.exchangeRates.getRates(trip) : null; } /** @@ -895,26 +1014,23 @@ export class BudgetService { // is the identity, so behaviour is unchanged. // rates[X] = units of X per 1 base; the frozen exchange_rate is units of item-currency // per 1 trip-currency. Pre-rework rows store currency = NULL = "the trip's own currency". - const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => { - const cur = (itemCurrency || tripCurrency).toUpperCase(); - if (cur === tripCurrency) return amount; - // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate - // it was booked at, so a later live-rate drift doesn't re-open it with a residual. - if (itemRate != null && itemRate > 0 && itemRate !== 1) return amount / itemRate; - // Legacy rows without a frozen rate: convert via base with live rates. - if (!rates) return amount; - const rCur = rates[cur]; - const rTrip = rates[tripCurrency]; - if (rCur && rCur > 0 && rTrip && rTrip > 0) return (amount / rCur) * rTrip; - return amount; - }; + // Prefer the FX rate frozen at entry time (#1335): a settled expense keeps the rate + // it was booked at, so a later live-rate drift doesn't re-open it with a residual. + // Legacy rows without a frozen rate convert via base with live rates. + const toTrip = (amount: number, itemCurrency: string | null | undefined, itemRate?: number | null): number => + toTripAmount(amount, itemCurrency, itemRate, tripCurrency, rates); // trip-currency → display currency, applied once to the final netted totals. // Held as a plain factor so it is exactly linear: the balances are converted as // one set (allocateDisplayCents) rather than one at a time, which is what keeps // them adding up to zero in whatever currency the viewer picked (#1382). - const displayFactor = base === tripCurrency - ? 1 - : (rates && rates[tripCurrency] > 0 ? 1 / rates[tripCurrency] : 1); + // + // `rates` may be quoted against any base; the ratio of the two quotes is the + // factor either way. Callers pass the trip currency's own quote (see settlement()), + // the one freezeForeignRate froze every entry rate from, so a bill entered today in + // the display currency converts back to exactly what was typed (#2525). Taken the + // other way round, from the display currency's quote, the two quotes are not exact + // inverses and 12,345.67 USD came back as 12,346.06. + const displayFactor = base === tripCurrency ? 1 : quoteRatio(rates, base, tripCurrency); // A recorded settle-up amount is entered in whatever display currency the payer // was viewing. New rows capture that currency and the rate frozen at settle time // (#1445), so a settled position stays balanced when live rates drift — mirroring @@ -934,7 +1050,8 @@ export class BudgetService { } return amount; } - return base === tripCurrency ? amount : (rates && rates[tripCurrency] > 0 ? amount * rates[tripCurrency] : amount); + // The inverse of the display factor, so such a transfer reads back as entered. + return amount / displayFactor; }; const items = this.db.all('SELECT * FROM budget_items WHERE trip_id = ?', tripId); @@ -974,6 +1091,12 @@ export class BudgetService { // cents rather than converting the expense list a second time on the client. const frontedRows: Record = {}; const movedRows: Record = {}; + // Read in the trip currency, those figures are the ledger's own trip cents. Read in + // another one, they are built from each row's unrounded trip amount instead: a trip + // cent is worth more than a dollar cent on a euro trip, so a bill of 12,345.67 USD + // rounded to 10,831.44 EUR first came back as 12,345.68 (#2525). The balances, and + // with them settle-up, still net whole trip cents. + const finalUnit = (exactTripCents: number) => (displayFactor === 1 ? Math.round(exactTripCents) : exactTripCents); for (const item of items) { const members = allMembers.filter(m => m.budget_item_id === item.id); @@ -1003,10 +1126,11 @@ export class BudgetService { // refund (#2176) — is debited by the same arithmetic: their credit is negative. let creditCents = 0; for (const p of payers) { - const paid = toTripCents(p.amount, item.currency, item.exchange_rate); + const exact = toTrip(p.amount, item.currency, item.exchange_rate) * 100; + const paid = Math.round(exact); ensure(p.user_id, p).cents += paid; - frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + paid; - if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: paid }); + frontedCents[p.user_id] = (frontedCents[p.user_id] || 0) + finalUnit(exact); + if (paid !== 0) (frontedRows[p.user_id] ??= []).push({ item_id: item.id, cents: finalUnit(exact) }); creditCents += paid; } // …and each split participant owes their share — a custom per-member amount @@ -1043,17 +1167,19 @@ export class BudgetService { for (const s of settlements) { // Rounded to a trip cent per transfer, so recording one in a display currency // can't leave a sliver of a cent behind to accumulate over a trip's lifetime. - const inTrip = Math.round(settleToTrip(s.amount, s.currency, s.exchange_rate) * 100); + const exact = settleToTrip(s.amount, s.currency, s.exchange_rate) * 100; + const inTrip = Math.round(exact); ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).cents += inTrip; ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).cents -= inTrip; // Net of the transfers in both directions: sending one back is a reimbursement // received in reverse, and netting them is what keeps the final budget's // subtraction equal to the balance it is taken from. - reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + inTrip; - reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - inTrip; + const shown = finalUnit(exact); + reimbursedCents[s.to_user_id] = (reimbursedCents[s.to_user_id] || 0) + shown; + reimbursedCents[s.from_user_id] = (reimbursedCents[s.from_user_id] || 0) - shown; const moved = { settlement_id: s.id, from_user_id: s.from_user_id, to_user_id: s.to_user_id }; - (movedRows[s.to_user_id] ??= []).push({ ...moved, cents: inTrip }); - (movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -inTrip }); + (movedRows[s.to_user_id] ??= []).push({ ...moved, cents: shown }); + (movedRows[s.from_user_id] ??= []).push({ ...moved, cents: -shown }); } // Into the display currency as one set, then simplify — balances and flows are @@ -1237,14 +1363,24 @@ export class BudgetService { return this.listBudgetItems(tripId); } - perPersonSummary(tripId: string) { - return this.getPerPersonSummary(tripId); + async perPersonSummary(tripId: string | number) { + const trip = this.db.get<{ currency?: string | null }>('SELECT currency FROM trips WHERE id = ?', tripId); + return this.getPerPersonSummary(tripId, await this.ratesForTripTotals(tripId, trip?.currency || 'EUR')); } - async settlement(tripId: string, base: string | undefined, tripCurrency: string) { - const effectiveBase = (base || tripCurrency || 'EUR').toUpperCase(); - const rates = await this.exchangeRates.getRates(effectiveBase); - return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency }); + /** + * The settlement in the viewer's currency, for REST and MCP alike. Converted with the + * trip currency's own quote, the one every entry rate was frozen from, so a same-day + * amount in the display currency round-trips to the cent and paying what settle-up + * offers closes the balance (#2525). The display currency's quote only stands in when + * the trip's cannot be fetched. + */ + async settlement(tripId: string | number, base: string | undefined, tripCurrency: string) { + const trip = (tripCurrency || 'EUR').toUpperCase(); + const effectiveBase = (base || trip).toUpperCase(); + const rates = (await this.exchangeRates.getRates(trip)) + ?? (effectiveBase === trip ? null : await this.exchangeRates.getRates(effectiveBase)); + return this.calculateSettlement(tripId, { base: effectiveBase, rates, tripCurrency: trip }); } async create(tripId: string, data: Parameters[1]) { diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index af8509e21..bbb21280c 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -26,7 +26,7 @@ import { type ReservationBody = Record & { title?: string; type?: string; - create_budget_entry?: { total_price?: number; category?: string }; + create_budget_entry?: { total_price?: number; category?: string; currency?: string | null }; }; /** @@ -61,7 +61,7 @@ export class ReservationsController { @RequirePermission('reservation_edit') @Post() - create( + async create( @CurrentUser() user: User, @Param('tripId') tripId: string, @Body() rawBody: ReservationCreateDto, @@ -69,11 +69,14 @@ export class ReservationsController { ) { const body = rawBody as ReservationBody & { title: string }; this.rejectForeignReferences(tripId, body); + // Before the synchronous writes: the price keeps the currency it was quoted in, + // at a rate frozen now (#2525). + const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry); const { reservation, accommodationCreated } = this.reservations.create(tripId, body as never); if (accommodationCreated) { this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId); } - this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, body.create_budget_entry, socketId); + this.reservations.syncBudgetOnCreate(tripId, reservation.id, body.title, body.type, budgetEntry, socketId); this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId); this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? ''); return { reservation }; diff --git a/server/src/nest/reservations/reservations.rpc.ts b/server/src/nest/reservations/reservations.rpc.ts index 73a664d49..794d7d3dd 100644 --- a/server/src/nest/reservations/reservations.rpc.ts +++ b/server/src/nest/reservations/reservations.rpc.ts @@ -30,7 +30,7 @@ export class ReservationsRpc { ) {} @PluginMethod('reservations.create', { permission: 'db:write:reservations' }) - create(params: Record, ctx: PluginRpcContext): unknown { + async create(params: Record, ctx: PluginRpcContext): Promise { const tripId = num(params.tripId, 'tripId'); const actor = this.guards.requireActor(ctx, 'reservation'); const parsed = reservationCreateRequestSchema.safeParse(params.input); @@ -39,10 +39,12 @@ export class ReservationsRpc { this.requireValidEndpoints(input.endpoints); this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION); this.requireOwnReferences(tripId, input); + const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; + // Same as the REST route: the price keeps its currency, at a rate frozen now (#2525). + const budgetEntry = await this.reservations.withFrozenRate(tripId, i.create_budget_entry as never); const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never); if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined); - const i = input as { title?: string; type?: string; create_budget_entry?: unknown }; - this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, i.create_budget_entry as never, undefined); + this.reservations.syncBudgetOnCreate(String(tripId), reservation.id, i.title ?? '', i.type, budgetEntry, undefined); this.realtime.broadcast(tripId, 'reservation:created', { reservation }, undefined); this.notifyBooking(actor, tripId, i.title ?? '', i.type ?? ''); return reservation; diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index eb4ae18a1..bbbb918ed 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -12,7 +12,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { AccommodationsService, noStayMirror, type AccommodationMirror } from '../accommodations/accommodations.service'; type Trip = TripAccess; -type BudgetEntry = { total_price?: number; category?: string } | undefined; +type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined; export interface ReservationEndpoint { id?: number; @@ -1028,6 +1028,25 @@ export class ReservationsService { return answer; } + /** + * The linked cost a new booking's price becomes, in the currency the price was quoted + * in and at the rate frozen for it now (#2525). An imported booking previewed its + * $801.76 in dollars, then stored 801.76 in the trip's own currency, because only the + * amount travelled. The rate is resolved here, before the synchronous writes, the way + * the direct booking import and the Costs routes resolve it. A currency that is not a + * three-letter code is dropped, which leaves the price in the trip currency as before, + * and a rate is never taken from the caller. + */ + async withFrozenRate(tripId: string | number, entry: BudgetEntry): Promise { + if (!entry || typeof entry !== 'object') return entry; + const { currency: rawCurrency, exchange_rate: _callerRate, ...rest } = entry; + const currency = typeof rawCurrency === 'string' ? rawCurrency.trim().toUpperCase() : ''; + if (!/^[A-Z]{3}$/.test(currency)) return rest; + const priced: { currency?: string | null; exchange_rate?: number } = { currency }; + await this.budget.freezeForeignRate(tripId, priced); + return { ...rest, currency, ...(priced.exchange_rate != null ? { exchange_rate: priced.exchange_rate } : {}) }; + } + /** POST side effect: auto-create a linked budget item when a price is provided. */ syncBudgetOnCreate(tripId: string, reservationId: number, title: string, type: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { if (!entry || !(Number(entry.total_price) > 0)) return; @@ -1036,6 +1055,8 @@ export class ReservationsService { name: title, category: entry.category || type || 'Other', total_price: entry.total_price!, + ...(entry.currency ? { currency: entry.currency } : {}), + ...(entry.exchange_rate != null ? { exchange_rate: entry.exchange_rate } : {}), }); this.realtime.broadcast(tripId, 'budget:created', { item }, socketId); } catch (err) { diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 39deb1952..dab7b9650 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -47,7 +47,7 @@ export class TripReadModelService { // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── - getTripSummary(tripId: number, viewerUserId?: number) { + async getTripSummary(tripId: number, viewerUserId?: number) { const trip = withoutFeedToken( this.db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined, ); @@ -63,10 +63,15 @@ export class TripReadModelService { const accommodations = this.accommodations.list(tripId); const budgetItems = this.budget.listBudgetItems(tripId); + // In the trip currency, each row at the rate it was booked at (#2525). A raw sum of + // total_price added a dollar bill to the euros and called the result euros. + const tripCurrency = String(trip.currency || 'EUR'); + const totals = this.budget.tripTotals(tripId, tripCurrency, await this.budget.ratesForTripTotals(tripId, tripCurrency)); const budget = { items: budgetItems, item_count: budgetItems.length, - total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0), + total: totals.total, + by_category: totals.byCategory, currency: trip.currency, }; diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 2f1506a10..991c94d16 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -43,18 +43,20 @@ export class TripPromptsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } const { trip, budget } = summary; const currency = trip?.currency || 'EUR'; - const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { - const cat = item.category || 'Uncategorized'; - acc[cat] = (acc[cat] || 0) + (item.total_price || 0); - return acc; - }, {} as Record); - const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0); + // The summary's own totals, each row in the trip currency at the rate it was booked + // at (#2525). Adding total_price up here printed a dollar bill as that many euros. + const byCategory: Record = {}; + for (const [cat, amount] of Object.entries(budget?.by_category || {})) { + const key = cat || 'Uncategorized'; + byCategory[key] = Math.round(((byCategory[key] || 0) + amount) * 100) / 100; + } + const total = budget?.total || 0; const lines = Object.entries(byCategory) .sort(([, a], [, b]) => b - a) .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) @@ -94,7 +96,9 @@ export class TripPromptsMcp { const lines = Object.entries(grouped).map(([cat, catItems]) => `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` ).join('\n\n'); - const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {}; + // Only the title is needed. The whole summary also adds up the budget, which can + // wait on a rates fetch for a row that never froze its rate. + const trip = this.trips.getRaw(tripId); return { description: `Packing list for "${trip?.title || tripId}"`, messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 6d581dbf0..5199dc221 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -265,7 +265,7 @@ export class TripsMcp { }) async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) { if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) return noAccess(); const R = canReadTrips(ctx.scopes); // Addon availability gates @@ -599,7 +599,7 @@ export class TripsMcp { if (!this.trips.canAccessTrip(tripId, ctx.userId)) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; } - const summary = this.readModel.getTripSummary(tripId, ctx.userId); + const summary = await this.readModel.getTripSummary(tripId, ctx.userId); if (!summary) { return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; } diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 04c4efd09..619e50f49 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -228,6 +228,27 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { } }); + it('GET /summary/per-person reads a dollar bill on a euro trip at its booked rate (#2525)', async () => { + const hotel = await request(server) + .post(`/api/trips/${tripId}/budget`) + .set('Cookie', sessionCookie(1)) + .send({ name: 'Aparthotel Silver', currency: 'USD', exchange_rate: 1.17, payers: [{ user_id: 1, amount: 801.76 }], member_ids: [1, 2] }); + expect(hotel.status).toBe(201); + + const res = await request(server) + .get(`/api/trips/${tripId}/budget/summary/per-person`) + .set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + // 801.76 USD at 1.17 is 685.26 EUR, half of it each. The summary used to put + // 400.88 on both and leave the reader to guess the currency. + const row = (uid: number) => res.body.summary.find((r: { user_id: number }) => r.user_id === uid); + expect(row(1)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' }); + expect(row(2)).toMatchObject({ total_assigned: 342.63, total_paid: 0, items_count: 1, currency: 'EUR' }); + + const del = await request(server).delete(`/api/trips/${tripId}/budget/${hotel.body.item.id}`).set('Cookie', sessionCookie(1)); + expect(del.status).toBe(200); + }); + it('200 on settlement update with permission, persisting the new amount and day', async () => { const created = await request(server) .post(`/api/trips/${tripId}/budget/settlements`) diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 3a8320e60..d0f63c2b8 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -57,6 +57,7 @@ let checkPermission: MockInstance; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; +import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => { let server: Server; @@ -67,6 +68,10 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] }) .overrideProvider(NotificationsService) .useValue({ send: notificationSend }) + // A price quoted in a foreign currency freezes the rate of the day; this is that + // day's table, so no case ever reaches the network. + .overrideProvider(ExchangeRatesService) + .useValue({ getRates: async (base: string) => (base.toUpperCase() === 'EUR' ? { EUR: 1, USD: 1.17 } : null) }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -133,6 +138,31 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect(res.body).toEqual({ error: 'Trip not found' }); }); + it('201 create keeps an imported price in the currency it was quoted in, at a frozen rate (#2525)', async () => { + const res = await request(server) + .post(`/api/trips/${tripId}/reservations`) + .set('Cookie', sessionCookie(1)) + .send({ + title: 'Aparthotel Silver', type: 'hotel', + metadata: { price: '801.76', priceCurrency: 'USD' }, + create_budget_entry: { total_price: 801.76, category: 'accommodation', currency: 'USD' }, + }); + expect(res.status).toBe(201); + // It used to land as 801.76 with no currency, which is 801.76 of the trip's euros. + const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id); + expect(item).toEqual({ total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }); + }); + + it('201 create leaves a price without a currency in the trip currency, as before', async () => { + const res = await request(server) + .post(`/api/trips/${tripId}/reservations`) + .set('Cookie', sessionCookie(1)) + .send({ title: 'Museum', type: 'event', create_budget_entry: { total_price: 20, currency: 'not a code' } }); + expect(res.status).toBe(201); + const item = db.prepare('SELECT total_price, currency, exchange_rate FROM budget_items WHERE reservation_id = ?').get(res.body.reservation.id); + expect(item).toEqual({ total_price: 20, currency: null, exchange_rate: 1 }); + }); + it('201 create reservation (real insert + booking notification), 400 without title', async () => { const ok = await request(server) .post(`/api/trips/${tripId}/reservations`) diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 4138d840b..c825852eb 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -374,6 +374,46 @@ describe('Settlement tools', () => { }); }); + it('get_settlement_summary reads a same-day bill in the viewer\'s currency to the cent, like REST (#2525)', async () => { + // Frankfurter's two quotes for the pair, each rounded, so not exact inverses. The entry + // rate is frozen from the euro's, and converting back with the dollar's read the + // 12,345.67 dollars as 12,346.06. + const quotes: Record> = { EUR: { EUR: 1, USD: 1.1398 }, USD: { USD: 1, EUR: 0.87732 } }; + const spy = vi.spyOn(ExchangeRatesService.prototype, 'getRates').mockImplementation(async (b: string) => quotes[b] ?? null); + try { + const { user, other, trip } = tripWithTwo(); + testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id); + const item = createBudgetItem(testDb, trip.id, { total_price: 12345.67 }); + testDb.prepare("UPDATE budget_items SET currency = 'USD', exchange_rate = 1.1398 WHERE id = ?").run(item.id); + testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0), (?, ?, 0)') + .run(item.id, user.id, item.id, other.id); + testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, ?)') + .run(item.id, user.id, 12345.67); + + const dbService = new DatabaseService(testDb); + const controller = new BudgetController( + new BudgetService(dbService, new PermissionsService(dbService), new ExchangeRatesService(), new RealtimeService()), + ); + const rest = await controller.settlement( + { id: user.id } as User, + { id: trip.id, user_id: user.id, currency: 'EUR' } as TripAccess, + String(trip.id), + 'USD', + ); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_settlement_summary', arguments: { tripId: trip.id, base: 'USD' } }); + const data = parseToolResult(result) as { summary: typeof rest }; + expect(data.summary).toEqual(rest); + const mine = data.summary.finalBudgets.find(f => f.user_id === user.id)!; + expect(mine.expenses).toBe(12345.67); + expect(mine.sources.fronted).toEqual([{ item_id: item.id, cents: 1234567 }]); + expect(data.summary.flows.map(f => f.amount)).toEqual([6172.84]); + }); + } finally { + spy.mockRestore(); + } + }); + it('get_settlement_summary returns balances and flows', async () => { try { const { user, other, trip } = tripWithTwo(); diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 3caa8ba43..ea953102a 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -93,6 +93,7 @@ import { notificationsStub } from '../../helpers/notifications'; // getTripSummary is the same controllable mock the legacy path used. const tripsStub = { canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId), + getRaw: (tripId: number) => testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId), } as unknown as TripsService; // getTripSummary moved to TripReadModelService with the trip split; the mock is // the same controllable one, one constructor slot further along. @@ -119,8 +120,9 @@ const promptDbs = () => new DatabaseService(testDb); const authStub = { isDemoUser: () => false } as unknown as AuthService; const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()); const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub, promptGuards); +const promptBudget = new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()); const budgetMcp = new BudgetMcp( - new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()), + promptBudget, new ExchangeRatesService(), promptDbs(), new RuntimeEnvService(), @@ -158,6 +160,8 @@ beforeEach(() => { `).all(tripId) as any[]; const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[]; const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[]; + // The totals come from the same BudgetService.tripTotals the real summary uses. + const totals = promptBudget.tripTotals(tripId, trip.currency || 'EUR'); return { trip, days: [], @@ -165,7 +169,8 @@ beforeEach(() => { budget: { items: budgetRows, item_count: budgetRows.length, - total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0), + total: totals.total, + by_category: totals.byCategory, currency: trip.currency, }, packing: packingRows, // array shape; packing prompt tolerates it @@ -418,19 +423,18 @@ describe('Prompt: packing-list', () => { expect(text).toMatch(/\[[ x]\]/); }); - it('uses tripId as title fallback when getTripSummary returns null (covers || {} branch)', async () => { + it('reads the trip title without building the whole trip summary (#2525)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Null Trip' }); createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Hygiene' }); - - // Null out the getTripSummary call inside packing-list (line 94: || {}) - mockGetTripSummary.mockReturnValueOnce(null); + mockGetTripSummary.mockClear(); const client = await buildServer(user.id); const text = await invokePromptText(client, 'packing-list', { tripId: trip.id }); expect(text).toContain('Toothbrush'); - // Falls back to 'Trip' literal since trip?.title is undefined (getTripSummary null → || {}) - expect(text).toContain('Packing List: Trip'); + expect(text).toContain('Packing List: Null Trip'); + // The summary adds the budget up, and that can wait on a rates fetch. + expect(mockGetTripSummary).not.toHaveBeenCalled(); }); }); @@ -513,6 +517,24 @@ describe('Prompt: budget-overview', () => { expect(text).toContain('300'); // Accommodation total }); + it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Dollar Trip' }); + promptBudget.createBudgetItem(trip.id, { + name: 'Aparthotel Silver', category: 'Accommodation', currency: 'USD', exchange_rate: 1.17, + payers: [{ user_id: user.id, amount: 801.76 }], members: [{ user_id: user.id }], + }); + createBudgetItem(testDb, trip.id, { name: 'Dinner', category: 'Food', total_price: 100 }); + + const client = await buildServer(user.id); + const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id }); + // 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR". + expect(text).toContain('Total: 785.26 EUR'); + expect(text).toContain('- Accommodation: 685.26 EUR'); + expect(text).toContain('- Food: 100 EUR'); + expect(text).not.toContain('801.76'); + }); + it('renders "No expenses recorded." when budget array is empty', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Empty Budget' }); diff --git a/server/tests/unit/nest/budget.controller.test.ts b/server/tests/unit/nest/budget.controller.test.ts index e33f96685..5df4a25dc 100644 --- a/server/tests/unit/nest/budget.controller.test.ts +++ b/server/tests/unit/nest/budget.controller.test.ts @@ -49,13 +49,13 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] }); }); - it('GET /summary/per-person + /settlement delegate', () => { + it('GET /summary/per-person + /settlement delegate', async () => { const settlement = vi.fn().mockReturnValue({ transfers: [] }); const svc = makeService({ perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]), settlement, } as Partial); - expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); + expect(await new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] }); // A trip with no currency set falls back to EUR rather than passing undefined on. expect(new BudgetController(svc).settlement(user, { id: 5, user_id: 42 } as never, '5')).toEqual({ transfers: [] }); expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR'); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 97ad73ae5..171401252 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -39,6 +39,9 @@ const { RATES } = vi.hoisted(() => ({ RATES: { RUB: { RUB: 1, USD: 0.013042, EUR: 0.011412 }, EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 }, + // The dollar's own quote, as Frankfurter prints it: rounded, so not the exact + // inverse of the euro's 1.1429 (1 / 0.87497 = 1.142896...). + USD: { USD: 1, EUR: 0.87497 }, } as Record>, })); // Constructor-injected since the fold; the class is mocked at the module path @@ -1156,3 +1159,157 @@ describe('an expense whose split leaves a remainder', () => { expect(after.id).toBe(before.id); }); }); + +// #2525: a bill entered in dollars on a euro trip was booked at the rate of the day. The +// totals the MCP summary, the budget prompt and the per-person resource hand out added its +// dollars to the euros as they stood and called the sum euros. +describe('trip totals read every row in the trip currency (#2525)', () => { + function seedDollarBill() { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + const members = [{ user_id: me.id }, { user_id: bob.id }]; + const hotel = budget.createBudgetItem(trip.id, { + name: 'Aparthotel Silver', category: 'accommodation', currency: 'USD', exchange_rate: 1.17, + payers: [{ user_id: me.id, amount: 801.76 }], members, + }); + budget.createBudgetItem(trip.id, { + name: 'Dinner', category: 'food', currency: 'EUR', + payers: [{ user_id: bob.id, amount: 100 }], members, + }); + return { trip, me, bob, hotel }; + } + + it('BUDGET-SVC-DB-044: adds each row up at the rate it was booked at, in whole cents', async () => { + const { trip } = seedDollarBill(); + // 801.76 USD at 1.17 is 685.26 EUR, the figure the settlement nets as well. + expect(budget.tripTotals(trip.id, 'EUR')).toEqual({ + total: 785.26, + byCategory: { accommodation: 685.26, food: 100 }, + }); + // Both rows carry what they need, so nothing waits on today's rates. + expect(await budget.ratesForTripTotals(trip.id, 'EUR')).toBeNull(); + }); + + it('BUDGET-SVC-DB-045: fetches today\'s rate only for a row that never froze one', async () => { + const { user: me } = createUser(testDb); + const trip = createTrip(testDb, me.id); + // Written before the freeze existed: the column default of 1 is not a booked rate. + budget.createBudgetItem(trip.id, { name: 'Old taxi', category: 'transport', total_price: 114.29, currency: 'USD' }); + const rates = await budget.ratesForTripTotals(trip.id, 'EUR'); + expect(rates).toEqual(RATES.EUR); + // 114.29 USD at 1.1429 per euro, the same way the settlement reads such a row. + expect(budget.tripTotals(trip.id, 'EUR', rates).total).toBe(100); + }); + + it('BUDGET-SVC-DB-046: the per-person summary splits trip cents, not raw amounts', async () => { + const { trip, me, bob, hotel } = seedDollarBill(); + budget.toggleMemberPaid(hotel.id, trip.id, bob.id, true); + + const summary = await budget.perPersonSummary(trip.id); + const of = (id: number) => summary.find(s => s.user_id === id)!; + // Half of 685.26 EUR plus half of 100 EUR each. The query this replaced put + // 450.88 on both, half the dollars counted as euros, and never said which. + expect(of(me.id)).toMatchObject({ total_assigned: 392.63, total_paid: 0, items_count: 2, currency: 'EUR' }); + expect(of(bob.id)).toMatchObject({ total_assigned: 392.63, total_paid: 342.63, items_count: 2, currency: 'EUR' }); + }); + + it('BUDGET-SVC-DB-047: an equal split hands out the odd cent instead of a float tail', async () => { + const { user: a } = createUser(testDb); + const { user: b } = createUser(testDb); + const { user: c } = createUser(testDb); + const trip = createTrip(testDb, a.id); + addTripMember(testDb, trip.id, b.id); + addTripMember(testDb, trip.id, c.id); + budget.createBudgetItem(trip.id, { + name: 'Boat', currency: 'EUR', payers: [{ user_id: a.id, amount: 100 }], + members: [{ user_id: a.id }, { user_id: b.id }, { user_id: c.id }], + }); + + const shares = (await budget.perPersonSummary(trip.id)).map(s => Math.round(s.total_assigned * 100)); + expect(shares.slice().sort((x, y) => x - y)).toEqual([3333, 3333, 3334]); + expect(shares.reduce((x, y) => x + y, 0)).toBe(10000); + }); +}); + +// #2525: a euro trip read in dollars froze a dollar bill's rate from the euro's quote and +// converted the ledger back with the dollar's. The two quotes are not exact inverses, so a +// bill entered today read a few cents off and paying what settle-up offered left a balance. +describe('the settlement converts with the quote the entry rate was frozen from (#2525)', () => { + function seedSameDayBill() { + const { user: me } = createUser(testDb); + const { user: bob } = createUser(testDb); + const trip = createTrip(testDb, me.id); + addTripMember(testDb, trip.id, bob.id); + return { trip, me, bob }; + } + + it('BUDGET-SVC-DB-048: a same-day bill in the display currency reads as typed and settles to zero', async () => { + const { trip, me, bob } = seedSameDayBill(); + // Frozen now, from the euro's quote: 1.1429. + const bill = await budget.create(String(trip.id), { + name: 'Villa', category: 'accommodation', currency: 'USD', total_price: 12345.67, + payers: [{ user_id: me.id, amount: 12345.67 }], members: [{ user_id: me.id }, { user_id: bob.id }], + }) as { id: number; exchange_rate: number }; + expect(bill.exchange_rate).toBe(1.1429); + + const before = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = before.finalBudgets.find(f => f.user_id === me.id)!; + expect(mine.expenses).toBe(12345.67); + expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 1234567 }]); + expect(before.flows.map(f => f.amount)).toEqual([6172.84]); + + // Bob pays exactly what settle-up offers, in dollars, as the Costs screen records it. + await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 6172.84, currency: 'USD' }, me.id); + const after = await budget.settlement(trip.id, 'USD', 'EUR'); + expect(after.balances.map(b => b.balance)).toEqual([0, 0]); + expect(after.flows).toEqual([]); + expect((await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance)).toEqual([0, 0]); + }); + + it('BUDGET-SVC-DB-051: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => { + const { trip, me, bob } = seedSameDayBill(); + // 123.45 USD is 108.0147 EUR. As a whole trip cent, 108.01 EUR, it came back as 123.44. + const bill = await budget.create(String(trip.id), { + name: 'Taxi', currency: 'USD', total_price: 123.45, + payers: [{ user_id: me.id, amount: 123.45 }], members: [{ user_id: me.id }, { user_id: bob.id }], + }) as { id: number }; + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = s.finalBudgets.find(f => f.user_id === me.id)!; + const bobs = s.finalBudgets.find(f => f.user_id === bob.id)!; + expect(mine.sources.fronted).toEqual([{ item_id: bill.id, cents: 12345 }]); + expect(mine.expenses).toBe(123.45); + // The two shares still add up to the bill, and the ledger in euros is untouched. + expect(Math.round((mine.final + bobs.final) * 100)).toBe(12345); + const inEur = await budget.settlement(trip.id, 'EUR', 'EUR'); + expect(inEur.finalBudgets.find(f => f.user_id === me.id)!.sources.fronted).toEqual([{ item_id: bill.id, cents: 10801 }]); + }); + + it('BUDGET-SVC-DB-049: a transfer saved without a currency reads back in the display currency as entered', async () => { + const { trip, me, bob } = seedSameDayBill(); + await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: 30 }, me.id); + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + const mine = s.finalBudgets.find(f => f.user_id === me.id)!; + expect(mine.reimbursed).toBe(30); + expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(-30); + }); + + it('BUDGET-SVC-DB-050: falls back to the display currency\'s quote when the trip\'s is unavailable', async () => { + const { trip, me, bob } = seedSameDayBill(); + budget.createBudgetItem(trip.id, { + name: 'Dinner', currency: 'EUR', payers: [{ user_id: me.id, amount: 100 }], + members: [{ user_id: me.id }, { user_id: bob.id }], + }); + const rates = (budget as unknown as { exchangeRates: ExchangeRatesService }).exchangeRates; + const spy = vi.spyOn(rates, 'getRates').mockImplementation(async (base: string) => (base === 'EUR' ? null : RATES[base] ?? null)); + try { + const s = await budget.settlement(trip.id, 'USD', 'EUR'); + expect(spy.mock.calls.map(c => c[0])).toEqual(['EUR', 'USD']); + // 50 EUR at 1 / 0.87497 USD per euro. + expect(s.balances.find(b => b.user_id === me.id)!.balance).toBe(57.14); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/tests/unit/nest/budget.service.test.ts b/server/tests/unit/nest/budget.service.test.ts index f7317cc7b..b28bb0edd 100644 --- a/server/tests/unit/nest/budget.service.test.ts +++ b/server/tests/unit/nest/budget.service.test.ts @@ -73,24 +73,36 @@ describe('BudgetService', () => { expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock'); }); - it('list / perPersonSummary resolve through the folded SQL methods', () => { + it('list / perPersonSummary resolve through the folded SQL methods', async () => { const s = svc(); const listSpy = vi.spyOn(s, 'listBudgetItems').mockReturnValue([{ id: 1 }] as never); expect(s.list('5')).toEqual([{ id: 1 }]); expect(listSpy).toHaveBeenCalledWith('5'); const summarySpy = vi.spyOn(s, 'getPerPersonSummary').mockReturnValue([{ userId: 1 }] as never); - expect(s.perPersonSummary('5')).toEqual([{ userId: 1 }]); - expect(summarySpy).toHaveBeenCalledWith('5'); + expect(await s.perPersonSummary('5')).toEqual([{ userId: 1 }]); + // No row is waiting on today's rates, so none are fetched for it. + expect(summarySpy).toHaveBeenCalledWith('5', null); + expect(getRates).not.toHaveBeenCalled(); }); describe('settlement', () => { - it('upper-cases the explicit base and forwards the rates', async () => { + it('upper-cases the explicit base and converts with the trip currency\'s own quote (#2525)', async () => { const s = svc(); const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); - getRates.mockResolvedValue({ USD: 1.1 }); - await s.settlement('5', 'usd', 'EUR'); - expect(getRates).toHaveBeenCalledWith('USD'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' }); + getRates.mockResolvedValue({ EUR: 1, USD: 1.1 }); + await s.settlement('5', 'usd', 'eur'); + // The quote every entry rate was frozen from; the dollar's is not its exact inverse. + expect(getRates.mock.calls).toEqual([['EUR']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { EUR: 1, USD: 1.1 }, tripCurrency: 'EUR' }); + }); + + it('stands in the display currency\'s quote when the trip\'s cannot be fetched', async () => { + const s = svc(); + const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); + getRates.mockImplementation(async (b: string) => (b === 'USD' ? { USD: 1, EUR: 0.9 } : null)); + await s.settlement('5', 'USD', 'EUR'); + expect(getRates.mock.calls).toEqual([['EUR'], ['USD']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1, EUR: 0.9 }, tripCurrency: 'EUR' }); }); it('falls back to the trip currency when no base is given', async () => { @@ -98,8 +110,8 @@ describe('BudgetService', () => { const calcSpy = vi.spyOn(s, 'calculateSettlement').mockReturnValue({ transfers: [] } as never); getRates.mockResolvedValue(null); await s.settlement('5', undefined, 'gbp'); - expect(getRates).toHaveBeenCalledWith('GBP'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' }); + expect(getRates.mock.calls).toEqual([['GBP']]); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'GBP' }); }); it('falls back to EUR when neither base nor trip currency is present', async () => { @@ -108,7 +120,7 @@ describe('BudgetService', () => { getRates.mockResolvedValue(null); await s.settlement('5', undefined, ''); expect(getRates).toHaveBeenCalledWith('EUR'); - expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' }); + expect(calcSpy).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: 'EUR' }); }); }); diff --git a/server/tests/unit/nest/reservations.controller.test.ts b/server/tests/unit/nest/reservations.controller.test.ts index bd21888d2..5bcb0171d 100644 --- a/server/tests/unit/nest/reservations.controller.test.ts +++ b/server/tests/unit/nest/reservations.controller.test.ts @@ -21,10 +21,22 @@ function makeService(overrides: Partial = {}): Reservations syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), notifyBookingChange: vi.fn(), + // Hands the entry back as it came: what freezing does to it is the service's test. + withFrozenRate: vi.fn(async (_tripId: string, entry: unknown) => entry), ...overrides, } as unknown as ReservationsService; } +/** The create route awaits the price's rate, so its 400s arrive as a rejection. */ +async function rejected(promise: Promise): Promise<{ status: number; body: unknown }> { + try { await promise; } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected throw'); +} + function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -46,31 +58,31 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese // The bespoke 'Title is required' 400 moved to the global ZodValidationPipe // (ReservationCreateDto) — covered by the e2e suite. - it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => { + it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', async () => { const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true }); const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial); const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } }; - expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); + expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock'); expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); }); - it('400s on a body id belonging to another trip, without writing', () => { + it('400s on a body id belonging to another trip, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']), } as Partial); const body = { title: 'Hotel', accommodation_id: 4711 }; - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('400s on a body id that exists nowhere, in its own words, without writing', () => { + it('400s on a body id that exists nowhere, in its own words, without writing', async () => { const create = vi.fn(); const svc = makeService({ create, @@ -79,20 +91,36 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese const body = { title: 'Hotel', place_id: 4711 }; // Not 'Not part of this trip': an id that is part of nothing would send // the caller looking for it on another trip. - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', body))) .toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } }); expect(create).not.toHaveBeenCalled(); }); - it('answers a foreign id with the older message when it is both', () => { + it('answers a foreign id with the older message when it is both', async () => { const svc = makeService({ create: vi.fn(), referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']), unresolvedReferences: vi.fn().mockReturnValue(['place_id']), } as Partial); - expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) + expect(await rejected(new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 }))) .toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } }); }); + + // #2525: an imported booking quoted in dollars. The price has to reach the linked + // cost in dollars, at a rate frozen before anything is written. + it('hands the budget sync the entry with its currency and frozen rate', async () => { + const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false }); + const syncBudgetOnCreate = vi.fn(); + const withFrozenRate = vi.fn(async () => { + expect(create).not.toHaveBeenCalled(); + return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }; + }); + const svc = makeService({ create, syncBudgetOnCreate, withFrozenRate } as Partial); + const body = { title: 'Aparthotel Silver', type: 'hotel', create_budget_entry: { total_price: 801.76, currency: 'usd' } }; + await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock'); + expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' }); + expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Aparthotel Silver', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock'); + }); }); describe('PUT /positions', () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index a43cece1e..778363c93 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -39,7 +39,7 @@ const permissionsStub = { checkPermission } as unknown as PermissionsService; // Constructor-injected since the budget fold (was a path mock of the deleted // services/budgetService). -const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() }; +const budget = { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn(), freezeForeignRate: vi.fn() }; const { notif } = vi.hoisted(() => ({ notif: { send: vi.fn().mockResolvedValue(undefined) } })); @@ -532,6 +532,37 @@ describe('ReservationsService (DI-native, real SQL)', () => { budget.linkBudgetItemToReservation.mockImplementation(() => { throw new Error('boom'); }); expect(() => svc.syncBudgetOnCreate('5', 9, 'Hotel', undefined, { total_price: 50 }, 'sock')).not.toThrow(); }); + + // #2525: an imported booking quoted in dollars became a cost of that many euros. + it('links the cost in the currency and at the rate it arrives with', () => { + budget.linkBudgetItemToReservation.mockReturnValue({ id: 7 }); + svc.syncBudgetOnCreate('5', 9, 'Hotel', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock'); + expect(budget.linkBudgetItemToReservation).toHaveBeenCalledWith('5', 9, { + name: 'Hotel', category: 'hotel', total_price: 801.76, currency: 'USD', exchange_rate: 1.17, + }); + }); + }); + + describe('withFrozenRate (#2525)', () => { + it('keeps a quoted currency and freezes a rate for it', async () => { + budget.freezeForeignRate.mockImplementation(async (_tripId: unknown, data: { exchange_rate?: number }) => { data.exchange_rate = 1.17; }); + expect(await svc.withFrozenRate('5', { total_price: 801.76, category: 'hotel', currency: ' usd ' })).toEqual({ + total_price: 801.76, category: 'hotel', currency: 'USD', exchange_rate: 1.17, + }); + expect(budget.freezeForeignRate).toHaveBeenCalledWith('5', { currency: 'USD', exchange_rate: 1.17 }); + }); + + it('leaves the rate off when none could be frozen, so the cost converts live', async () => { + budget.freezeForeignRate.mockResolvedValue(undefined); + expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'EUR' })).toEqual({ total_price: 10, currency: 'EUR' }); + }); + + it('drops a currency that is not a code, and never takes a rate from the caller', async () => { + expect(await svc.withFrozenRate('5', { total_price: 10, currency: 'dollars', exchange_rate: 99 })).toEqual({ total_price: 10 }); + expect(await svc.withFrozenRate('5', { total_price: 10, exchange_rate: 99 })).toEqual({ total_price: 10 }); + expect(budget.freezeForeignRate).not.toHaveBeenCalled(); + expect(await svc.withFrozenRate('5', undefined)).toBeUndefined(); + }); }); describe('syncBudgetOnUpdate', () => { diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index f00cbab23..e2382565c 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -158,31 +158,31 @@ function ownerlessDbs(): DatabaseService { // ── Tests ───────────────────────────────────────────────────────────────────── describe('getTripSummary guards', () => { - it('TRIP-READ-001: returns null for a missing trip instead of throwing', () => { + it('TRIP-READ-001: returns null for a missing trip instead of throwing', async () => { // The MCP get_trip_summary tool hands over whatever id the model produced, so // an unknown id has to come back as an empty answer; a throw there surfaces as // a tool error rather than "no such trip". - expect(svc.getTripSummary(99999)).toBeNull(); - expect(svc.getTripSummary(99999, 1)).toBeNull(); + expect(await svc.getTripSummary(99999)).toBeNull(); + expect(await svc.getTripSummary(99999, 1)).toBeNull(); }); - it('TRIP-READ-002: returns null when the owner row cannot be read', () => { + it('TRIP-READ-002: returns null when the owner row cannot be read', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); // Trip row and owner id are two separate SELECTs; if the second one comes back // empty (trip deleted in between) the guard has to stop. Without it listMembers // runs with an undefined owner id and the summary reports an ownerless trip. - expect(buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull(); + expect(await buildReadModel(ownerlessDbs()).getTripSummary(trip.id, owner.id)).toBeNull(); // Same trip through the real connection still aggregates — the null above is // the missing owner row, not a broken fixture. - expect(svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); + expect(await svc.getTripSummary(trip.id, owner.id)).not.toBeNull(); }); }); describe('getTripSummary shaping', () => { - it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', () => { + it('TRIP-READ-003: folds a falsy total_price into the budget total instead of poisoning it', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addBudgetItem(trip.id, 'Dinner', 40); @@ -193,13 +193,43 @@ describe('getTripSummary shaping', () => { // the offline clients both render as an empty budget. addBudgetItem(trip.id, 'Free walking tour', 0); - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.budget.item_count).toBe(2); expect(summary.budget.total).toBe(40); expect(summary.budget.currency).toBe('EUR'); }); - it('TRIP-READ-004: counts only checked packing items, not the whole list', () => { + it('TRIP-READ-008: totals a foreign-currency bill in the trip currency, at its booked rate (#2525)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addBudgetItem(trip.id, 'Dinner', 100); + // 801.76 USD booked when a euro bought 1.17 dollars: 685.26 EUR of trip money. The + // summary used to add the 801.76 to the euros and report 901.76 EUR. + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'accommodation', 'Aparthotel Silver', 801.76, 'USD', 1.17)") + .run(trip.id); + + const summary = (await svc.getTripSummary(trip.id, owner.id))!; + expect(summary.budget.total).toBe(785.26); + expect(summary.budget.by_category).toEqual({ food: 100, accommodation: 685.26 }); + expect(summary.budget.currency).toBe('EUR'); + }); + + it('TRIP-READ-009: totals a trip saved without a currency in euros, the default the rest of the app reads it in (#2525)', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + testDb.prepare('UPDATE trips SET currency = NULL WHERE id = ?').run(trip.id); + addBudgetItem(trip.id, 'Dinner', 100); + // 117.33 USD booked at 1.1733 dollars to the euro is 100 EUR of trip money. + testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate) VALUES (?, 'transport', 'Taxi', 117.33, 'USD', 1.1733)") + .run(trip.id); + + const summary = (await svc.getTripSummary(trip.id, owner.id))!; + expect(summary.budget.total).toBe(200); + expect(summary.budget.by_category).toEqual({ food: 100, transport: 100 }); + expect(summary.budget.currency).toBeNull(); + }); + + it('TRIP-READ-004: counts only checked packing items, not the whole list', async () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); addPackingItem(trip.id, 'Socks', 1); @@ -208,7 +238,7 @@ describe('getTripSummary shaping', () => { // total and checked come from the same array; if the filter is ever widened the // packing progress the summary reports jumps to 100% while items are still open. - const summary = svc.getTripSummary(trip.id, owner.id)!; + const summary = (await svc.getTripSummary(trip.id, owner.id))!; expect(summary.packing.total).toBe(3); expect(summary.packing.checked).toBe(2); }); @@ -254,7 +284,7 @@ describe('bundle shaping', () => { }); describe('private packing items stay viewer-scoped (#858)', () => { - it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", () => { + it("TRIP-READ-007: neither summary nor bundle leaks another member's private item", async () => { const { user: owner } = createUser(testDb); const { user: viewer } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -267,7 +297,7 @@ describe('private packing items stay viewer-scoped (#858)', () => { // ONLY thing filtering the list. If either call site loses it, listItems falls // back to the unfiltered query and the surprise the owner is carrying shows up // in the other member's MCP summary and in their offline cache. - const asViewer = svc.getTripSummary(trip.id, viewer.id)!; + const asViewer = (await svc.getTripSummary(trip.id, viewer.id))!; expect(asViewer.packing.items.map((i: any) => i.name)).toEqual(['Tent']); expect(asViewer.packing.total).toBe(1); @@ -276,7 +306,7 @@ describe('private packing items stay viewer-scoped (#858)', () => { // The owner still sees their own private item through both paths, so the // assertions above are the filter working, not an empty fixture. - expect(svc.getTripSummary(trip.id, owner.id)!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); + expect((await svc.getTripSummary(trip.id, owner.id))!.packing.items.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); expect((svc.bundle(String(trip.id), { user_id: owner.id }, owner.id) as any) .packingItems.map((i: any) => i.name)).toEqual(['Ring', 'Tent']); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 5f021858e..1e640b8d8 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -928,7 +928,7 @@ describe('guest members (#1362)', () => { // ── Folded CRUD SQL (summary / list / create / delete / copy) ───────────────── describe('folded trip CRUD', () => { - it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => { + it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' }); @@ -936,7 +936,7 @@ describe('folded trip CRUD', () => { testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id); testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id); - const summary = readModelSvc.getTripSummary(trip.id, owner.id)!; + const summary = (await readModelSvc.getTripSummary(trip.id, owner.id))!; expect(summary).toBeTruthy(); expect((summary.trip as any).id).toBe(trip.id); expect(summary.members.owner.id).toBe(owner.id); @@ -950,7 +950,7 @@ describe('folded trip CRUD', () => { expect(summary.collab_notes).toEqual([]); // Missing trips return null instead of throwing. - expect(readModelSvc.getTripSummary(99999)).toBeNull(); + expect(await readModelSvc.getTripSummary(99999)).toBeNull(); }); it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => { diff --git a/server/tests/unit/reservations/bookings.rpc.test.ts b/server/tests/unit/reservations/bookings.rpc.test.ts index 0cbac2d25..628f719ee 100644 --- a/server/tests/unit/reservations/bookings.rpc.test.ts +++ b/server/tests/unit/reservations/bookings.rpc.test.ts @@ -43,6 +43,8 @@ function build(opts: { canEdit?: boolean; cascade?: boolean; stop?: boolean; see ), syncBudgetOnCreate: vi.fn(), syncBudgetOnUpdate: vi.fn(), + // The price's currency and rate are the service's to resolve; here it passes through. + withFrozenRate: vi.fn(async (_tripId: number, entry: unknown) => entry), notifyBookingChange: vi.fn(), // Both guards run before a write now, so the fixture answers for them. referencesOutsideTrip: vi.fn(() => opts.foreign ?? []),