fix(costs): read an expense entered in the display currency as it was typed (#2525)

A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked costs are labelled in the trip currency (every linked
row, the link picker and the phone's linked costs), a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
This commit is contained in:
Maurice
2026-09-25 19:48:12 +02:00
parent 6ed2561c63
commit 44a12e21fc
47 changed files with 1938 additions and 233 deletions
@@ -1,4 +1,4 @@
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-018
// FE-PLANNER-COSTSEC-001 to FE-PLANNER-COSTSEC-019
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -206,13 +206,17 @@ describe('BookingCostsSection', () => {
expect(screen.queryByRole('button', { name: /Create expense/ })).not.toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-016: amounts without a currency use the display currency, then the trip currency', () => {
it('FE-PLANNER-COSTSEC-016: an amount without a currency is in the trip currency, whatever the display currency (#2525)', () => {
// The booking saves its cost without a currency, which the server and Costs
// read as the trip's. Previewing it in the display currency said 12 GBP for
// what was then stored as 12 JPY.
seedStore(useTripStore, { budgetItems: [], trip: buildTrip({ id: 1, currency: 'JPY' }) });
seedStore(useSettingsStore, { settings: { default_currency: 'gbp' } });
const { unmount } = render(
<BookingCostsSection reservationId={null} pendingExpense={{ total_price: 12, category: 'food' }} onCreate={vi.fn()} onEdit={vi.fn()} onRemove={vi.fn()} />,
);
expect(screen.getByText(money(12, 'GBP'))).toBeInTheDocument();
expect(screen.getByText(money(12, 'JPY'))).toBeInTheDocument();
expect(screen.queryByText(money(12, 'GBP'))).not.toBeInTheDocument();
unmount();
seedStore(useSettingsStore, { settings: { default_currency: '' } });
@@ -235,4 +239,18 @@ describe('BookingCostsSection', () => {
expect(screen.getByText('Linked expenses')).toBeInTheDocument();
expect(screen.getByText('Flight LH 400')).toBeInTheDocument();
});
it('FE-PLANNER-COSTSEC-019: linked and offered expenses without a currency read in the trip currency (#2525)', async () => {
const user = userEvent.setup();
const deposit = buildBudgetItem({ id: 17, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: null, category: 'accommodation', reservation_id: 9 });
const tram = buildBudgetItem({ id: 18, trip_id: 1, name: 'Tram pass', total_price: 9, currency: null, category: 'transport' });
seedStore(useTripStore, { budgetItems: [deposit, tram] });
seedStore(useSettingsStore, { settings: { default_currency: 'USD' } });
renderSection();
expect(within(rowOf('Hotel deposit')).getByText(money(120, 'EUR'))).toBeInTheDocument();
expect(screen.queryByText(money(120, 'USD'))).not.toBeInTheDocument();
await user.click(linkSelect());
expect(screen.getByRole('button', { name: /Tram pass/ })).toHaveTextContent(money(9, 'EUR'));
});
});
@@ -38,6 +38,10 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
const tripCurrency = useTripStore(s => s.trip?.currency)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || tripCurrency || '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 || tripCurrency || base
const { targetId, linked, unlinked, link, unlink } = useExpenseLinks(reservationId, placeId)
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
@@ -60,7 +64,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t(meta.labelKey)}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(hintKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, ownCurrency(pendingExpense.currency), locale)}</span>
</div>
</div>
)
@@ -82,7 +86,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
<div className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t(meta.labelKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, item.currency || base, locale)}</span>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{formatMoney(item.total_price, ownCurrency(item.currency), locale)}</span>
<button type="button" onClick={() => onEdit(item)} title={t('common.edit')} aria-label={t('common.edit')} className={iconButton} style={iconButtonStyle}><Pencil size={13} /></button>
<button type="button" onClick={() => void unlink(item)} title={t('reservations.unlinkExpense')} aria-label={t('reservations.unlinkExpense')} className={iconButton} style={iconButtonStyle}><Link2Off size={13} /></button>
<button type="button" onClick={() => onRemove(item)} title={t('reservations.removeExpense')} aria-label={t('reservations.removeExpense')} className={deleteButton} style={iconButtonStyle}><Trash2 size={13} /></button>
@@ -114,7 +118,7 @@ export function BookingCostsSection({ reservationId, placeId = null, hintKey = '
return {
value: i.id,
label: i.name,
badge: formatMoney(i.total_price, i.currency || base, locale),
badge: formatMoney(i.total_price, ownCurrency(i.currency), locale),
icon: <Icon size={13} style={{ color: meta.color }} />,
}
})}
@@ -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';
@@ -1049,7 +1049,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 () => {
@@ -1360,6 +1360,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(
<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />,
);
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(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
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(<ReservationModal {...defaultProps} onSave={onSave} prefill={prefill} days={reviewDays()} />);
// 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', () => {
@@ -19,6 +19,7 @@ import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation,
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { BookingTypeSelect } from './BookingTypeSelect'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -290,11 +291,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<string, unknown>) : {}
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
@@ -336,12 +334,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<string, unknown>) : 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]
@@ -1144,7 +1144,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 ─────────────────────────────────────────────────────────
@@ -19,6 +19,7 @@ import { parseReservationMetadata, orderedEndpoints, stripAirportCode } from '..
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { BookingTypeSelect } from './BookingTypeSelect'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -603,11 +604,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<string, unknown>) : {}
const price = Number(pmeta.price)
if (Number.isFinite(price) && price > 0) {
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
const entry = importedPriceEntry(prefill.metadata, form.type)
if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
}
const saved = await onSave(payload)
// Persist the traveler assignment once we have the reservation id (create → save
@@ -651,12 +649,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<string, unknown>) : 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<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -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<string, unknown>) : {}
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 } : {}),
}
}