mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user