diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 868a41834..155d23528 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -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 () => { diff --git a/client/src/components/Budget/costsModel.test.ts b/client/src/components/Budget/costsModel.test.ts index a72a9d081..d087b73ce 100644 --- a/client/src/components/Budget/costsModel.test.ts +++ b/client/src/components/Budget/costsModel.test.ts @@ -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); }); diff --git a/client/src/components/Budget/costsModel.ts b/client/src/components/Budget/costsModel.ts index 1c3b00c8e..3022e6017 100644 --- a/client/src/components/Budget/costsModel.ts +++ b/client/src/components/Budget/costsModel.ts @@ -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). */ diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index 4c589ac8a..46b0ce9b2 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -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 = { 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) }) }) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 9218e5b64..0063dc435 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -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 diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index aaf6691e6..393f2b75b 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -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(); }); }); diff --git a/client/src/pages/sharedTrip/SharedListsViews.tsx b/client/src/pages/sharedTrip/SharedListsViews.tsx index 49842e525..b1043d0f8 100644 --- a/client/src/pages/sharedTrip/SharedListsViews.tsx +++ b/client/src/pages/sharedTrip/SharedListsViews.tsx @@ -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 } 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. diff --git a/client/tests/unit/mobile/trip/MCostSheet.test.tsx b/client/tests/unit/mobile/trip/MCostSheet.test.tsx index f4e6bb9a9..179f0531d 100644 --- a/client/tests/unit/mobile/trip/MCostSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MCostSheet.test.tsx @@ -681,7 +681,7 @@ describe('MCostSheet', () => { }))) }) - it('FE-MOB-COSTSH-036: editing a booked bill previews the rate the save keeps (#2525)', () => { + it('FE-MOB-COSTSH-036: editing a bill in the display currency has nothing to preview (#2965)', () => { // 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. @@ -695,12 +695,9 @@ describe('MCostSheet', () => { }) 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() + // The list reads the dollars typed, so the sheet has no conversion to show. + expect(screen.queryByText(/live rate/)).toBeNull() + expect(screen.getByText(/Split 2 ways · \$400\.88 each/)).toBeInTheDocument() }) it('FE-MOB-COSTSH-037: a bill booked at today\'s rate has nothing to preview (#2525)', () => { diff --git a/client/tests/unit/mobile/trip/MCostsTab.test.tsx b/client/tests/unit/mobile/trip/MCostsTab.test.tsx index 74c17efa5..a95e1dee4 100644 --- a/client/tests/unit/mobile/trip/MCostsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MCostsTab.test.tsx @@ -764,7 +764,7 @@ describe('MCostsTab', () => { expect(update).toHaveBeenCalledTimes(1) }) - it('FE-MOB-COSTT-045: a bill whose rate moved shows what was entered and what it was booked at (#2525)', async () => { + it('FE-MOB-COSTT-045: a bill whose rate moved still reads as the dollars typed (#2965)', 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 @@ -793,17 +793,16 @@ describe('MCostsTab', () => { 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(within(card).queryByText(/^\$801\.76 → /)).toBeNull() + expect(within(card).getByText('$801.76')).toBeInTheDocument() + // Each share is half of the dollars typed, the figure the balances net (#2965). + expect(within(card).getAllByText('$400.88')).toHaveLength(2) + expect(up(screen.getByText('costs.totalSpend'), 1).textContent).toContain('$801.76') + expect(screen.getByText('costs.spent:$801.76')).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(paid).getByText('$400.88')).toBeInTheDocument() expect(within(up(screen.getByText('Bob → costs.you'), 2)).getByText('$30.00')).toBeInTheDocument() expect(screen.queryByText('$34.65')).toBeNull() }) diff --git a/client/tests/unit/mobile/trip/costsModel.test.ts b/client/tests/unit/mobile/trip/costsModel.test.ts index 94feb7b50..0fa54b6a8 100644 --- a/client/tests/unit/mobile/trip/costsModel.test.ts +++ b/client/tests/unit/mobile/trip/costsModel.test.ts @@ -325,17 +325,14 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => { 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-038: a bill booked at a rate that has moved since still counts as the dollars typed (#2965)', () => { + // The balances net it as typed too, so the row, the payer's figure and the share + // read the dollars entered, and paying back half of them squares the trip. + expect(baseTotal(hotel(), usdCtx)).toBe(801.76); + expect(myPaidOf(hotel(), usdCtx)).toBe(801.76); + expect(myShareOf(hotel(), usdCtx)).toBe(400.88); + // Nothing to explain under it. + expect(lineOf(801.76, hotel(), usdCtx, baseTotal(hotel(), usdCtx))).toBeNull(); }); it('FE-MOB-CMOD-039: booked at today\'s rate, the bill reads exactly as typed and needs no line', () => { @@ -380,7 +377,7 @@ describe('costsModel: a bill entered in the display currency (#2525)', () => { ].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;$/); + expect(inUsd[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/); // 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'); diff --git a/server/tests/unit/nest/budget.service.calc.test.ts b/server/tests/unit/nest/budget.service.calc.test.ts index b78437726..f455b6ea8 100644 --- a/server/tests/unit/nest/budget.service.calc.test.ts +++ b/server/tests/unit/nest/budget.service.calc.test.ts @@ -1583,9 +1583,9 @@ describe('calculateSettlement: bills in the display currency (#2965)', () => { const [alice, bob] = result.balances.map((b) => b.balance); expect([540.54, 540.55]).toContain(alice); expect(bob).toBe(-alice); - const fronted = result.finalBudgets.find((f) => f.user_id === 1)!; - expect(fronted.expenses).toBe(1081.09); - expect(fronted.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]); + const fronted = result.finalBudgets.find((f) => f.user_id === 1); + expect(fronted?.expenses).toBe(1081.09); + expect(fronted?.sources.fronted).toEqual([{ item_id: 1, cents: 108109 }]); }); it('a repayment of exactly what is owed in euros squares the trip, frozen at any rate', async () => { @@ -1593,8 +1593,12 @@ describe('calculateSettlement: bills in the display currency (#2965)', () => { const owed = (await budget.calculateSettlement(1, today)).flows[0].amount; // Bob pays the euros the flow names, frozen at today's yen rate. await setupDb(flight(), pair(), paid(), [makeSettlementRow(5, 2, 1, owed, 'EUR', 1 / 176.73)]); - for (const eurPerYen of [1 / 176.73, 1 / 170, 1 / 185]) { - const result = await budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }); + const results = await Promise.all( + [1 / 176.73, 1 / 170, 1 / 185].map((eurPerYen) => + budget.calculateSettlement(1, { ...today, rates: { JPY: 1, EUR: eurPerYen } }), + ), + ); + for (const result of results) { expect(result.balances.map((b) => b.balance)).toEqual([0, 0]); expect(result.flows).toEqual([]); for (const f of result.finalBudgets) expect(f.final).toBeCloseTo(f.user_id === 1 ? 1081.09 - owed : owed, 2); diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 1422696ca..ba32067e5 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -1807,14 +1807,20 @@ describe('the settlement converts with the quote the entry rate was frozen from 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]); + const offered = before.flows.map(f => f.amount); + expect(offered).toHaveLength(1); + expect([6172.83, 6172.84]).toContain(offered[0]); // 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); + await budget.createSettlement(trip.id, { from_user_id: bob.id, to_user_id: me.id, amount: offered[0], 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]); + // Read in the trip currency both go through the euro, each rounded to a euro cent + // on its own, so at most that cent stays apart (#2965); it still adds up to zero. + const inEur = (await budget.settlement(trip.id, 'EUR', 'EUR')).balances.map(b => b.balance); + expect(inEur.every(b => Math.abs(b) <= 0.01)).toBe(true); + expect(Math.round(inEur[0] * 100) + Math.round(inEur[1] * 100)).toBe(0); }); it('BUDGET-SVC-DB-083: the final budget lists a bill at what was typed even where its trip cent rounds away', async () => {