diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d6546d0c8370..e7823f9a3cb5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1284,6 +1284,8 @@ const CONST = { EUR: 'EUR', }, DEFAULT_CURRENCY_DECIMALS: 2, + // Number of decimals an exchange rate is rounded and padded to for display, matching Expensify Classic. + EXCHANGE_RATE_DISPLAY_DECIMALS: 4, SCA_CURRENCIES: new Set(['GBP', 'EUR']), get DIRECT_REIMBURSEMENT_CURRENCIES() { return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR]; diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 562c7efa660a..aaafd9e2d605 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -241,7 +241,7 @@ function TransactionItemRow({ }; const description = getDescription(transactionItem); - const exchangeRateMessage = getExchangeRate(transactionItem, report?.currency ?? policy?.outputCurrency); + const exchangeRateMessage = getExchangeRate(transactionItem, report?.currency ?? policy?.outputCurrency, true); const cardName = getCompanyCardDescription(translate, transactionItem?.cardName, transactionItem?.cardID, nonPersonalAndWorkspaceCards, transactionItem?.feedCountry); const isUnreported = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const shouldShowAttendees = (isUnreported ? !!isAttendeesEnabledForMovingPolicy : shouldShowAttendeesUtils(CONST.IOU.TYPE.SUBMIT, policy)) && transactionAttendees.length > 0; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 734d3253be78..42b523dc4cd1 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1481,9 +1481,24 @@ function getTagArrayFromName(tagName: string): string[] { } /** - * Returns the exchange rate for a transaction, based on its group or currencyConversionRate + * Caps an exchange rate at 4 decimals for display, matching Expensify Classic, which rounds and pads to + * exactly 4 decimals (`0.272294077603812` -> `0.2723`, `1.5` -> `1.5000`). `toFixed` handles the exponential + * form small rates stringify into (`7.27431439586819e-7` -> `0.0000`), and a finite guard passes a + * non-numeric rate through untouched so we never render `NaN`. */ -function getExchangeRate(transaction: TransactionWithOptionalSearchFields, reportCurrency?: string) { +function formatExchangeRateForDisplay(rate: string | number): string { + const parsedRate = Number(rate); + return Number.isFinite(parsedRate) ? parsedRate.toFixed(CONST.EXCHANGE_RATE_DISPLAY_DECIMALS) : String(rate); +} + +/** + * Returns the exchange rate for a transaction, based on its group or currencyConversionRate. + * + * When `shouldFormatRate` is true (display only), the rate is rounded and padded to exactly 4 decimals + * to match Expensify Classic. The default (false) keeps the raw value so the non-display consumers, the + * search/report sort keys and the emptiness predicate, compare on the full precision exactly as they do today. + */ +function getExchangeRate(transaction: TransactionWithOptionalSearchFields, reportCurrency?: string, shouldFormatRate = false) { const fromCurrency = getCurrency(transaction); // On the report view, "unconverted" means the transaction currency matches the report currency. @@ -1497,7 +1512,8 @@ function getExchangeRate(transaction: TransactionWithOptionalSearchFields, repor if (transaction.groupExchangeRate != null && transaction.groupCurrency && fromCurrency !== transaction.groupCurrency) { const groupRate = Number(transaction.groupExchangeRate); if (groupRate !== 1) { - return `${transaction.groupExchangeRate} ${fromCurrency}/${transaction.groupCurrency}`; + const rate = shouldFormatRate ? formatExchangeRateForDisplay(transaction.groupExchangeRate) : transaction.groupExchangeRate; + return `${rate} ${fromCurrency}/${transaction.groupCurrency}`; } } @@ -1509,7 +1525,8 @@ function getExchangeRate(transaction: TransactionWithOptionalSearchFields, repor if (conversionToCurrency && transaction.currencyConversionRate != null && fromCurrency !== conversionToCurrency) { const conversionRate = Number(transaction.currencyConversionRate); if (conversionRate !== 1) { - return `${transaction.currencyConversionRate} ${fromCurrency}/${conversionToCurrency}`; + const rate = shouldFormatRate ? formatExchangeRateForDisplay(transaction.currencyConversionRate) : transaction.currencyConversionRate; + return `${rate} ${fromCurrency}/${conversionToCurrency}`; } } diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 1a8045cc822a..865ea3d412fe 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -4155,6 +4155,113 @@ describe('TransactionUtils', () => { expect(TransactionUtils.getExchangeRate(transaction, 'EUR')).toBe(''); }); + + describe('shouldFormatRate (display formatting to 4 decimals, matching Expensify Classic)', () => { + it('rounds a rate with more than 4 decimals rather than truncating', () => { + const transaction = generateTransaction({ + currency: 'USD', + groupExchangeRate: 13768.5157822803, + groupCurrency: 'EUR', + amount: -100, + convertedAmount: -1376851, + }); + + // toFixed(4) rounds .51578… up to .5158 (truncation would give .5157). + expect(TransactionUtils.getExchangeRate(transaction, undefined, true)).toBe('13768.5158 USD/EUR'); + }); + + it('rounds, not truncates, on the values where the two rules differ', () => { + const aed = generateTransaction({ + currency: 'AED', + currencyConversionRate: '0.272294077603812', + amount: -100, + convertedAmount: -27, + }); + const ron = generateTransaction({ + currency: 'RON', + currencyConversionRate: '0.220361392684002', + amount: -100, + convertedAmount: -22, + }); + + // Classic renders 0.2723 and 0.2204; truncation would give 0.2722 and 0.2203. + expect(TransactionUtils.getExchangeRate(aed, 'USD', true)).toBe('0.2723 AED/USD'); + expect(TransactionUtils.getExchangeRate(ron, 'USD', true)).toBe('0.2204 RON/USD'); + }); + + it('formats an exponential rate to 0.0000 instead of mangling it', () => { + const transaction = generateTransaction({ + currency: 'IRR', + groupExchangeRate: 7.27431439586819e-7, + groupCurrency: 'USD', + amount: -100, + convertedAmount: -1, + }); + + // Classic renders 0.0000; the string-split truncation approach returned 7.2743 here. + expect(TransactionUtils.getExchangeRate(transaction, undefined, true)).toBe('0.0000 IRR/USD'); + }); + + it('formats an exponential rate string to 0.0000', () => { + // The reported bug arrived through currencyConversionRate (typed string), so the real input is the + // zero-padded exponent string rather than the number the groupExchangeRate case above covers. + const transaction = generateTransaction({ + currency: 'IRR', + currencyConversionRate: '7.27431439586819e-07', + amount: -100, + convertedAmount: -1, + }); + + expect(TransactionUtils.getExchangeRate(transaction, 'USD', true)).toBe('0.0000 IRR/USD'); + }); + + it('pads a rate with fewer than 4 decimals to exactly 4', () => { + const transaction = generateTransaction({ + currency: 'USD', + groupExchangeRate: 1.5, + groupCurrency: 'EUR', + amount: -100, + convertedAmount: -150, + }); + + expect(TransactionUtils.getExchangeRate(transaction, undefined, true)).toBe('1.5000 USD/EUR'); + }); + + it('formats the "0.0" string the backend can return to 0.0000', () => { + const transaction = generateTransaction({ + currency: 'UZS', + currencyConversionRate: '0.0', + amount: -5000, + convertedAmount: -1, + }); + + expect(TransactionUtils.getExchangeRate(transaction, 'USD', true)).toBe('0.0000 UZS/USD'); + }); + + it('renders a non-numeric rate verbatim instead of NaN', () => { + const transaction = generateTransaction({ + currency: 'USD', + currencyConversionRate: 'invalid', + groupCurrency: 'EUR', + amount: -100, + convertedAmount: -85, + }); + + // Number('invalid') is NaN, so the finite guard falls back to the raw string. + expect(TransactionUtils.getExchangeRate(transaction, undefined, true)).toBe('invalid USD/EUR'); + }); + + it('leaves the rate untouched without the flag, so the sorts and the emptiness predicate are unaffected', () => { + const transaction = generateTransaction({ + currency: 'AED', + currencyConversionRate: '0.272294077603812', + amount: -100, + convertedAmount: -27, + }); + + expect(TransactionUtils.getExchangeRate(transaction, 'USD')).toBe('0.272294077603812 AED/USD'); + }); + }); }); describe('mergeProhibitedViolations', () => {