fix(costs): keep convertBooked at five parameters and update the tests to as-typed display rows

The display-currency check is its own helper (inDisplayCurrency), used by the Costs model and the shared trip page. The tests that pinned #2525's reading of a display-currency bill through the trip currency now pin the as-typed reading, which the balances use too; read in the trip currency, such a bill and its repayment may stay a euro cent apart.
This commit is contained in:
Maurice
2026-11-03 22:43:05 +01:00
parent a37a9c0fa8
commit 4585988dfd
12 changed files with 109 additions and 101 deletions
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
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()
// A bill in the display currency counts as typed (#2965), as the balances net it:
// row total and payer chip read the dollars entered, nothing to explain under it,
// and what I lent is half of them, which settle-up offers too.
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
expect(screen.getAllByText('$801.76')).toHaveLength(2)
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
})
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
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;$/)
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
} finally {
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
}
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
])
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()
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
// $921.76, on the day header and on the total spend card.
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
expect(screen.getByText('$921.76 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')
expect(card.textContent).toContain('$921.76')
})
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
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 () => {
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', 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' },
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
})
await screen.findByText('Aparthotel Silver')
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
// The hotel's split chips can carry the same figure, so look past them to the payment.
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
expect(outsideChips('$395.77')).toHaveLength(1)
expect(await screen.findByText('$30.00')).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
expect(outsideChips('$400.88')).toHaveLength(1)
expect(outsideChips('$30.00')).toHaveLength(1)
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 () => {
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
const user = userEvent.setup()
mount([hotel()])
await screen.findByText('Aparthotel Silver')
await user.click(screen.getByRole('button', { name: /^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(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
// The list reads the dollars typed, so there is no conversion to preview.
expect(screen.queryByText(/live rate/)).toBeNull()
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
})
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
// Entered in the display currency, it reads as entered (#2965).
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
});
+14 -12
View File
@@ -1,5 +1,5 @@
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
import type { BudgetItem } from '../../types';
import { currencyDecimals } from '../../utils/formatters';
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
@@ -30,9 +30,13 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
return (e.currency || ctx.tripCurrency).toUpperCase();
}
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
/**
* An amount of this expense in the display currency, at the rate the expense was booked at;
* as typed when it was entered in the display currency (#2965).
*/
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert, ctx.displayCurrency);
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/**
@@ -130,16 +134,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
return (s.currency || ctx.displayCurrency).toUpperCase();
}
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
/**
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
* as entered when it was made in the display currency (#2965).
*/
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
return convertBooked(
s.amount,
paymentCurrency(s, ctx),
s.exchange_rate,
ctx.tripCurrency,
ctx.convert,
ctx.displayCurrency
);
const currency = paymentCurrency(s, ctx);
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
}
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
+6 -11
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import { bookedInTrip, convertBooked, convertedLine, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
import { bookedInTrip, convertBooked, convertedLine, inDisplayCurrency, crossRate, fetchExchangeRates, fetchFreshRates, freshRates, clearExchangeRateCache, tripAmountOf, useExchangeRates, withFallbackFx } from './useExchangeRates'
const FX_URL = 'https://api.frankfurter.dev/v2/rates'
@@ -198,16 +198,11 @@ describe('convertBooked', () => {
})
// #2965: read in dollars, a bill entered in dollars counts as typed, as the balances net it.
it('shows a row in the display currency as typed, whatever rate it was frozen at', () => {
const liveUsd = (amount: number, from: string | null | undefined): number => {
const rates: Record<string, number> = { USD: 1, EUR: 1 / 1.1398 }
const r = rates[(from || 'USD').toUpperCase()]
return r && r > 0 ? amount / r : amount
}
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd, 'USD')).toBe(801.76)
expect(convertBooked(801.76, 'usd', 1.17, 'EUR', liveUsd, 'USD')).toBe(801.76)
// Another currency still goes through the trip currency.
expect(convertBooked(801.76, 'USD', 1.17, 'EUR', liveUsd, 'GBP')).toBeCloseTo((801.76 / 1.17) * 1.1398, 6)
it('knows a row entered in the display currency, a NULL currency being the trip one', () => {
expect(inDisplayCurrency('usd', 'EUR', 'USD')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'eur')).toBe(true)
expect(inDisplayCurrency(null, 'EUR', 'USD')).toBe(false)
expect(inDisplayCurrency('USD', 'EUR', 'GBP')).toBe(false)
})
})
+13 -5
View File
@@ -133,19 +133,27 @@ export function convertBooked(
frozenRate: number | null | undefined,
tripCurrency: string,
convertLive: (amount: number, from: string | null | undefined) => number,
displayCurrency?: string,
): number {
const trip = (tripCurrency || 'EUR').toUpperCase()
// A row entered in the display currency is shown as typed (#2965): through the
// trip currency at its frozen rate and back at today's, it drifted off the
// amount entered, and the balances (which net such rows as typed) disagreed.
if (displayCurrency && (rowCurrency || trip).toUpperCase() === displayCurrency.toUpperCase()) return amount
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())
}
/**
* Whether a row was entered in the display currency (#2965). Such a row counts as typed:
* through the trip currency at its frozen rate and back at today's it drifted off the
* amount entered, and the balances, which net such rows as typed, would disagree.
*/
export function inDisplayCurrency(
rowCurrency: string | null | undefined,
tripCurrency: string,
displayCurrency: string,
): boolean {
return ((rowCurrency || tripCurrency) || '').toUpperCase() === (displayCurrency || '').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
+7 -7
View File
@@ -664,10 +664,10 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(4));
});
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).
it('reads a bill in the display currency as typed, as Costs does (#2965)', async () => {
// The owner reads in USD on a CHF trip. The USD bill reads as the 801.76 USD
// typed, as Costs and its balances count it; 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 }) => {
@@ -692,9 +692,9 @@ describe('SharedTripPage', () => {
fireEvent.click(screen.getByRole('button', { name: /costs/i }));
await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(3));
expect(screen.getAllByText('780.86 USD')).toHaveLength(3);
expect(screen.getByText('894.81 USD')).toBeInTheDocument();
expect(screen.getByText('801.76 USD')).toBeInTheDocument();
expect(screen.getAllByText('801.76 USD').length).toBeGreaterThan(0);
expect(screen.queryByText('780.86 USD')).toBeNull();
expect(screen.getByText('915.71 USD')).toBeInTheDocument();
expect(screen.getByText('100.00 CHF')).toBeInTheDocument();
});
});
@@ -3,7 +3,7 @@ import { catMeta } from '../../components/Budget/costsCategories'
import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell'
import { CountPill } from '../../components/Planner/bookings/bookingParts'
import { TripMemberAvatar } from '../../components/Trips/TripMemberAvatar'
import { convertBooked, convertedLine } from '../../hooks/useExchangeRates'
import { convertBooked, convertedLine, inDisplayCurrency } from '../../hooks/useExchangeRates'
import { useTranslation } from '../../i18n'
import { avatarSrc } from '../../utils/avatarSrc'
import { currencyDecimals } from '../../utils/formatters'
@@ -92,7 +92,11 @@ export function SharedCostsView({ items, base, tripCurrency, convert }: CostsPro
const { t, locale } = useTranslation()
if (items.length === 0) return <EmptySection icon={<Wallet size={22} />} text={t('shared.emptyCosts')} />
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, base)
// A bill in the owner's display currency reads as typed, as in Costs (#2965).
const valueOf = (i: Expense) =>
inDisplayCurrency(i.currency, tripCurrency, base)
? amountOf(i)
: convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert)
// In the reader's own number format with the currency's code: a public page has
// no idea where its reader is, and "$" alone does not say which dollar.
// Whole cents: a converted amount otherwise printed a third decimal.