Skip to content

Commit eb51454

Browse files
authored
Merge pull request Expensify#91114 from Expensify/fix/graceful-currency-code-handling
Gracefully handle malformed currency codes instead of crashing
2 parents 07ddf00 + e2df690 commit eb51454

9 files changed

Lines changed: 394 additions & 35 deletions

File tree

src/components/CurrencyListContextProvider/index.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, {createContext, useCallback, useContext, useMemo} from 'react';
22
import useLocalize from '@hooks/useLocalize';
33
import useOnyx from '@hooks/useOnyx';
4-
import {convertToFrontendAmountAsInteger} from '@libs/CurrencyUtils';
4+
import {convertToFrontendAmountAsInteger, sanitizeCurrencyCode} from '@libs/CurrencyUtils';
55
import {format, formatToParts} from '@libs/NumberFormatUtils';
66
import CONST from '@src/CONST';
77
import ONYXKEYS from '@src/ONYXKEYS';
@@ -34,15 +34,12 @@ function CurrencyListContextProvider({children}: React.PropsWithChildren) {
3434

3535
const convertToDisplayString = useCallback(
3636
(amountInCents: number | undefined, currencyCode: string | undefined): string => {
37-
const decimals = getCurrencyDecimals(currencyCode);
37+
const sanitizedCurrency = sanitizeCurrencyCode(currencyCode);
38+
const decimals = getCurrencyDecimals(sanitizedCurrency);
3839
const convertedAmount = convertToFrontendAmountAsInteger(amountInCents ?? 0, decimals);
39-
let currencyWithFallback = currencyCode;
40-
if (!currencyCode) {
41-
currencyWithFallback = CONST.CURRENCY.USD;
42-
}
4340
return format(preferredLocale, convertedAmount, {
4441
style: 'currency',
45-
currency: currencyWithFallback,
42+
currency: sanitizedCurrency,
4643

4744
// We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
4845
// See: https://github.com/Expensify/PHP-Libs/pull/834
@@ -56,11 +53,12 @@ function CurrencyListContextProvider({children}: React.PropsWithChildren) {
5653

5754
const convertToDisplayStringWithoutCurrency = useCallback(
5855
(amountInCents: number, currencyCode: string = CONST.CURRENCY.USD): string => {
59-
const decimals = getCurrencyDecimals(currencyCode);
56+
const sanitizedCurrency = sanitizeCurrencyCode(currencyCode);
57+
const decimals = getCurrencyDecimals(sanitizedCurrency);
6058
const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals);
6159
return formatToParts(preferredLocale, convertedAmount, {
6260
style: 'currency',
63-
currency: currencyCode,
61+
currency: sanitizedCurrency,
6462

6563
// We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
6664
// See: https://github.com/Expensify/PHP-Libs/pull/834

src/components/Search/SearchChartView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import useLocalize from '@hooks/useLocalize';
3-
import {getCurrencySymbol} from '@libs/CurrencyUtils';
3+
import {getCurrencySymbol, sanitizeCurrencyCode} from '@libs/CurrencyUtils';
44
import Log from '@libs/Log';
55
import Navigation from '@libs/Navigation/Navigation';
66
import {formatToParts} from '@libs/NumberFormatUtils';
@@ -71,7 +71,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar
7171
};
7272

7373
const firstItem = data.at(0);
74-
const currency = firstItem?.currency ?? 'USD';
74+
const currency = sanitizeCurrencyCode(firstItem?.currency ?? CONST.CURRENCY.USD);
7575
const parts = formatToParts(preferredLocale, 0, {style: 'currency', currency});
7676
const currencyIndex = parts.findIndex((p) => p.type === 'currency');
7777
const integerIndex = parts.findIndex((p) => p.type === 'integer');

src/components/TransactionItemRow/DataCells/TotalCell.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList';
88
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
99
import useLocalize from '@hooks/useLocalize';
1010
import useThemeStyles from '@hooks/useThemeStyles';
11-
import {convertToBackendAmount, convertToFrontendAmountAsString, getCurrencyDecimals} from '@libs/CurrencyUtils';
11+
import {convertToBackendAmount, convertToFrontendAmountAsString, getCurrencyDecimals, sanitizeCurrencyCode} from '@libs/CurrencyUtils';
1212
import {formatToParts} from '@libs/NumberFormatUtils';
1313
import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
1414
import {getTransactionDetails, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils';
@@ -121,10 +121,11 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,
121121
// Some currencies display with a space between symbol and amount (e.g., "CZK 100.00") in convertToDisplayString (in preview).
122122
// We detect this spacing and apply matching padding to the input to prevent visual flicker when entering edit mode.
123123
// See: https://github.com/Expensify/App/pull/83127#issuecomment-4240055145
124+
const sanitizedCurrency = sanitizeCurrencyCode(currency);
124125
const hasSymbolSpaceInPreview = formatToParts(preferredLocale, 0, {
125126
style: 'currency',
126-
currency,
127-
minimumFractionDigits: getCurrencyDecimals(currency),
127+
currency: sanitizedCurrency,
128+
minimumFractionDigits: getCurrencyDecimals(sanitizedCurrency),
128129
maximumFractionDigits: CONST.DEFAULT_CURRENCY_DECIMALS,
129130
}).some((part) => part.type === 'literal' && part.value.trim() === '');
130131

src/libs/CurrencyUtils.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import IntlStore from '@src/languages/IntlStore';
44
import type {OnyxValues} from '@src/ONYXKEYS';
55
import ONYXKEYS from '@src/ONYXKEYS';
66
import type {Locale} from '@src/types/onyx';
7+
import Log from './Log';
78
import {format, formatToParts} from './NumberFormatUtils';
89

910
let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {};
@@ -19,6 +20,46 @@ Onyx.connect({
1920
},
2021
});
2122

23+
/**
24+
* Returns true when the provided value is a syntactically valid ISO 4217 currency code
25+
* (exactly 3 uppercase ASCII letters).
26+
*/
27+
function isValidCurrencyCode(currencyCode: unknown): currencyCode is string {
28+
return typeof currencyCode === 'string' && /^[A-Z]{3}$/.test(currencyCode);
29+
}
30+
31+
// Tracks invalid currency codes already warned about so the same bad value doesn't spam the log on every render.
32+
const warnedInvalidCurrencyCodes = new Set<string>();
33+
34+
/**
35+
* Test-only: clears the in-memory de-dup of malformed currency codes so tests asserting on `Log.warn`
36+
* are not affected by warnings emitted by earlier tests. Production code should not call this.
37+
*/
38+
function resetInvalidCurrencyWarningsForTesting() {
39+
warnedInvalidCurrencyCodes.clear();
40+
}
41+
42+
/**
43+
* Validates a currency code and returns it unchanged if it is a valid ISO 4217 code.
44+
* Whitespace and case-only variations (e.g. " usd ") are normalized rather than discarded.
45+
* Returns CONST.CURRENCY.USD and logs a warning at most once per unique malformed value, to prevent Intl.NumberFormat
46+
* from throwing a RangeError. See https://github.com/Expensify/App/issues/91113
47+
*/
48+
function sanitizeCurrencyCode(currencyCode: unknown): string {
49+
if (isValidCurrencyCode(currencyCode)) {
50+
return currencyCode;
51+
}
52+
const normalized = typeof currencyCode === 'string' ? currencyCode.trim().toUpperCase() : '';
53+
if (isValidCurrencyCode(normalized)) {
54+
return normalized;
55+
}
56+
if (!warnedInvalidCurrencyCodes.has(normalized)) {
57+
warnedInvalidCurrencyCodes.add(normalized);
58+
Log.warn('CurrencyUtils: invalid currency code, defaulting to USD', {currencyCode});
59+
}
60+
return CONST.CURRENCY.USD;
61+
}
62+
2263
/**
2364
* Returns the number of digits after the decimal separator for a specific currency.
2465
* For currencies that have decimal places > 2, floor to 2 instead:
@@ -47,7 +88,7 @@ function getCurrencyUnit(currency: string = CONST.CURRENCY.USD): number {
4788
function getLocalizedCurrencySymbol(locale: Locale | undefined, currencyCode: string): string | undefined {
4889
const parts = formatToParts(locale, 0, {
4990
style: 'currency',
50-
currency: currencyCode,
91+
currency: sanitizeCurrencyCode(currencyCode),
5192
});
5293
return parts.find((part) => part.type === 'currency')?.value;
5394
}
@@ -97,15 +138,9 @@ function convertToFrontendAmountAsString(amountAsInt: number | null | undefined,
97138
* @param currency - IOU currency
98139
*/
99140
function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldUseLocalCurrencySymbol = false): string {
100-
const decimals = getCurrencyDecimals(currency);
141+
const currencyWithFallback = sanitizeCurrencyCode(currency);
142+
const decimals = getCurrencyDecimals(currencyWithFallback);
101143
const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals);
102-
/**
103-
* Fallback currency to USD if it empty string or undefined
104-
*/
105-
let currencyWithFallback = currency;
106-
if (!currency) {
107-
currencyWithFallback = CONST.CURRENCY.USD;
108-
}
109144

110145
if (shouldUseLocalCurrencySymbol) {
111146
const currencySymbol = getCurrencySymbol(currencyWithFallback);
@@ -153,7 +188,7 @@ function convertToShortDisplayString(amountInCents = 0, currency: string = CONST
153188

154189
return format(IntlStore.getCurrentLocale(), convertedAmount, {
155190
style: 'currency',
156-
currency,
191+
currency: sanitizeCurrencyCode(currency),
157192

158193
// There will be no decimals displayed (e.g. $9)
159194
minimumFractionDigits: 0,
@@ -171,7 +206,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE
171206
const convertedAmount = amount / 100.0;
172207
return format(IntlStore.getCurrentLocale(), convertedAmount, {
173208
style: 'currency',
174-
currency,
209+
currency: sanitizeCurrencyCode(currency),
175210
minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES,
176211
maximumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES,
177212
});
@@ -181,11 +216,12 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE
181216
* Acts the same as `convertAmountToDisplayString` but the result string does not contain currency
182217
*/
183218
function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) {
184-
const decimals = getCurrencyDecimals(currency);
219+
const sanitizedCurrency = sanitizeCurrencyCode(currency);
220+
const decimals = getCurrencyDecimals(sanitizedCurrency);
185221
const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals);
186222
return formatToParts(IntlStore.getCurrentLocale(), convertedAmount, {
187223
style: 'currency',
188-
currency,
224+
currency: sanitizedCurrency,
189225

190226
// We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
191227
// See: https://github.com/Expensify/PHP-Libs/pull/834
@@ -200,6 +236,9 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency:
200236
}
201237

202238
export {
239+
isValidCurrencyCode,
240+
sanitizeCurrencyCode,
241+
resetInvalidCurrencyWarningsForTesting,
203242
getCurrencyDecimals,
204243
getCurrencyUnit,
205244
getLocalizedCurrencySymbol,

src/libs/Formula.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {ValueOf} from 'type-fest';
44
import CONST from '@src/CONST';
55
import type {PersonalDetails, Policy, PolicyReportField, Report, Transaction} from '@src/types/onyx';
66
import {isEmptyObject} from '@src/types/utils/EmptyObject';
7-
import {convertToDisplayString, convertToDisplayStringWithoutCurrency} from './CurrencyUtils';
7+
import {convertToDisplayString, convertToDisplayStringWithoutCurrency, isValidCurrencyCode} from './CurrencyUtils';
88
import formatDate from './FormulaDatetime';
99
import getBase62ReportID from './getBase62ReportID';
1010
import Log from './Log';
@@ -567,24 +567,34 @@ function formatAmount(amount: number | undefined, currency: string | undefined,
567567
const absoluteAmount = Math.abs(amount);
568568

569569
try {
570+
const trimmedCurrency = currency?.trim().toUpperCase();
570571
const trimmedDisplayCurrency = displayCurrency?.trim().toUpperCase();
571572
if (trimmedDisplayCurrency) {
572573
if (trimmedDisplayCurrency === 'NOSYMBOL') {
573-
return convertToDisplayStringWithoutCurrency(absoluteAmount, currency);
574+
return convertToDisplayStringWithoutCurrency(absoluteAmount, trimmedCurrency);
574575
}
575576

576577
// If a currency conversion is needed (displayCurrency differs from the source),
577578
// return null so the backend can compute it.
578579
// We can only compute the value optimistically when the amount is 0.
579-
if (absoluteAmount !== 0 && currency !== trimmedDisplayCurrency) {
580+
if (absoluteAmount !== 0 && trimmedCurrency !== trimmedDisplayCurrency) {
580581
return null;
581582
}
582583

584+
// Return empty string for an unrecognized display currency so the placeholder is preserved upstream.
585+
if (!isValidCurrencyCode(trimmedDisplayCurrency)) {
586+
return '';
587+
}
588+
583589
return convertToDisplayString(absoluteAmount, trimmedDisplayCurrency);
584590
}
585591

586-
if (currency) {
587-
return convertToDisplayString(absoluteAmount, currency, true);
592+
if (trimmedCurrency) {
593+
// Return empty string for an unrecognized source currency so the placeholder is preserved upstream.
594+
if (!isValidCurrencyCode(trimmedCurrency)) {
595+
return '';
596+
}
597+
return convertToDisplayString(absoluteAmount, trimmedCurrency, true);
588598
}
589599

590600
return convertToDisplayStringWithoutCurrency(absoluteAmount, currency);
Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import intlPolyfill from '@libs/IntlPolyfill';
2+
import Log from '@libs/Log';
23
import memoize from '@libs/memoize';
34
import CONST from '@src/CONST';
45
import type Locale from '@src/types/onyx/Locale';
@@ -8,12 +9,45 @@ intlPolyfill();
89

910
const MemoizedNumberFormat = memoize(Intl.NumberFormat, {maxSize: 10, monitoringName: 'NumberFormatUtils'});
1011

12+
// Tracks malformed currency codes that have already produced a warning in this module, so the
13+
// safety-net log doesn't fire on every render for the same bad value.
14+
const warnedMalformedCurrencies = new Set<string>();
15+
16+
/**
17+
* Build an Intl.NumberFormat. If construction throws a RangeError due to a malformed currency
18+
* code (Intl rejects empty strings and non-ISO-4217 codes), fall back to USD instead of letting
19+
* the error crash the screen. We check `'currency' in options` rather than `options.currency`
20+
* truthiness so an empty-string currency still triggers the fallback.
21+
*/
22+
function createFormatter(locale: Locale | undefined, options?: Intl.NumberFormatOptions): Intl.NumberFormat {
23+
try {
24+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options);
25+
} catch (e) {
26+
if (e instanceof RangeError && options && 'currency' in options) {
27+
const currency = String(options.currency ?? '');
28+
if (!warnedMalformedCurrencies.has(currency)) {
29+
warnedMalformedCurrencies.add(currency);
30+
Log.warn('NumberFormatUtils: malformed currency code, falling back to USD', {currency: options.currency});
31+
}
32+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, {...options, currency: CONST.CURRENCY.USD});
33+
}
34+
throw e;
35+
}
36+
}
37+
1138
function format(locale: Locale | undefined, number: number, options?: Intl.NumberFormatOptions): string {
12-
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).format(number);
39+
return createFormatter(locale, options).format(number);
1340
}
1441

1542
function formatToParts(locale: Locale | undefined, number: number, options?: Intl.NumberFormatOptions): Intl.NumberFormatPart[] {
16-
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).formatToParts(number);
43+
return createFormatter(locale, options).formatToParts(number);
44+
}
45+
46+
/**
47+
* Test-only: clears the malformed-currency deduplication so warn-assertion tests don't pollute each other.
48+
*/
49+
function resetMalformedCurrenciesForTesting() {
50+
warnedMalformedCurrencies.clear();
1751
}
1852

19-
export {format, formatToParts};
53+
export {format, formatToParts, resetMalformedCurrenciesForTesting};

0 commit comments

Comments
 (0)