Skip to content

Commit c7a0ee6

Browse files
mountinycursoragent
andcommitted
Gracefully handle malformed currency codes instead of crashing
When a workspace policy or transaction has an invalid currency code stored in the backend, Intl.NumberFormat throws a RangeError that crashes the expense amount screen. Add two-layer protection: 1. sanitizeCurrencyCode helper in CurrencyUtils.ts: validates the format (ISO 4217 = exactly 3 uppercase ASCII letters) and falls back to USD with a Log.warn when the code is malformed. Applied in getLocalizedCurrencySymbol, convertToDisplayString, convertToDisplayStringWithoutCurrency, convertToShortDisplayString, and convertAmountToDisplayString. 2. try/catch safety net in NumberFormatUtils/index.ts: catches any RangeError from Intl.NumberFormat caused by a bad currency option that slips past layer 1, retries with USD, and logs a warning. Also sanitizes currency in TotalCell.tsx before its direct formatToParts call. Fixes Expensify#91113 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 96a097d commit c7a0ee6

3 files changed

Lines changed: 46 additions & 18 deletions

File tree

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: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Onyx from 'react-native-onyx';
22
import CONST from '@src/CONST';
33
import IntlStore from '@src/languages/IntlStore';
4+
import Log from '@src/libs/Log';
45
import type {OnyxValues} from '@src/ONYXKEYS';
56
import ONYXKEYS from '@src/ONYXKEYS';
67
import type {Locale} from '@src/types/onyx';
@@ -19,6 +20,19 @@ Onyx.connect({
1920
},
2021
});
2122

23+
/**
24+
* Validates a currency code and returns it unchanged if it is a valid ISO 4217 code (exactly 3 uppercase ASCII letters).
25+
* Returns CONST.CURRENCY.USD and logs a warning when the code is malformed or missing, to prevent Intl.NumberFormat
26+
* from throwing a RangeError. See https://github.com/Expensify/App/issues/91113
27+
*/
28+
function sanitizeCurrencyCode(currencyCode: string): string {
29+
if (/^[A-Z]{3}$/.test(currencyCode)) {
30+
return currencyCode;
31+
}
32+
Log.warn('CurrencyUtils: invalid currency code, defaulting to USD', {currencyCode});
33+
return CONST.CURRENCY.USD;
34+
}
35+
2236
/**
2337
* Returns the number of digits after the decimal separator for a specific currency.
2438
* For currencies that have decimal places > 2, floor to 2 instead:
@@ -47,7 +61,7 @@ function getCurrencyUnit(currency: string = CONST.CURRENCY.USD): number {
4761
function getLocalizedCurrencySymbol(locale: Locale | undefined, currencyCode: string): string | undefined {
4862
const parts = formatToParts(locale, 0, {
4963
style: 'currency',
50-
currency: currencyCode,
64+
currency: sanitizeCurrencyCode(currencyCode),
5165
});
5266
return parts.find((part) => part.type === 'currency')?.value;
5367
}
@@ -97,15 +111,9 @@ function convertToFrontendAmountAsString(amountAsInt: number | null | undefined,
97111
* @param currency - IOU currency
98112
*/
99113
function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldUseLocalCurrencySymbol = false): string {
100-
const decimals = getCurrencyDecimals(currency);
114+
const currencyWithFallback = sanitizeCurrencyCode(currency);
115+
const decimals = getCurrencyDecimals(currencyWithFallback);
101116
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-
}
109117

110118
if (shouldUseLocalCurrencySymbol) {
111119
const currencySymbol = getCurrencySymbol(currencyWithFallback);
@@ -153,7 +161,7 @@ function convertToShortDisplayString(amountInCents = 0, currency: string = CONST
153161

154162
return format(IntlStore.getCurrentLocale(), convertedAmount, {
155163
style: 'currency',
156-
currency,
164+
currency: sanitizeCurrencyCode(currency),
157165

158166
// There will be no decimals displayed (e.g. $9)
159167
minimumFractionDigits: 0,
@@ -171,7 +179,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE
171179
const convertedAmount = amount / 100.0;
172180
return format(IntlStore.getCurrentLocale(), convertedAmount, {
173181
style: 'currency',
174-
currency,
182+
currency: sanitizeCurrencyCode(currency),
175183
minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES,
176184
maximumFractionDigits: CONST.MAX_TAX_RATE_DECIMAL_PLACES,
177185
});
@@ -181,11 +189,12 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE
181189
* Acts the same as `convertAmountToDisplayString` but the result string does not contain currency
182190
*/
183191
function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) {
184-
const decimals = getCurrencyDecimals(currency);
192+
const sanitizedCurrency = sanitizeCurrencyCode(currency);
193+
const decimals = getCurrencyDecimals(sanitizedCurrency);
185194
const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, decimals);
186195
return formatToParts(IntlStore.getCurrentLocale(), convertedAmount, {
187196
style: 'currency',
188-
currency,
197+
currency: sanitizedCurrency,
189198

190199
// We are forcing the number of decimals because we override the default number of decimals in the backend for some currencies
191200
// See: https://github.com/Expensify/PHP-Libs/pull/834
@@ -200,6 +209,7 @@ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency:
200209
}
201210

202211
export {
212+
sanitizeCurrencyCode,
203213
getCurrencyDecimals,
204214
getCurrencyUnit,
205215
getLocalizedCurrencySymbol,
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import intlPolyfill from '@libs/IntlPolyfill';
22
import memoize from '@libs/memoize';
33
import CONST from '@src/CONST';
4+
import Log from '@src/libs/Log';
45
import type Locale from '@src/types/onyx/Locale';
56

67
// Polyfill the Intl API if locale data is not as expected
@@ -9,11 +10,27 @@ intlPolyfill();
910
const MemoizedNumberFormat = memoize(Intl.NumberFormat, {maxSize: 10, monitoringName: 'NumberFormatUtils'});
1011

1112
function format(locale: Locale | undefined, number: number, options?: Intl.NumberFormatOptions): string {
12-
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).format(number);
13+
try {
14+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).format(number);
15+
} catch (e) {
16+
if (e instanceof RangeError && options?.currency) {
17+
Log.warn('NumberFormatUtils: malformed currency code, falling back to USD', {currency: options.currency});
18+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, {...options, currency: CONST.CURRENCY.USD}).format(number);
19+
}
20+
throw e;
21+
}
1322
}
1423

1524
function formatToParts(locale: Locale | undefined, number: number, options?: Intl.NumberFormatOptions): Intl.NumberFormatPart[] {
16-
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).formatToParts(number);
25+
try {
26+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, options).formatToParts(number);
27+
} catch (e) {
28+
if (e instanceof RangeError && options?.currency) {
29+
Log.warn('NumberFormatUtils: malformed currency code, falling back to USD', {currency: options.currency});
30+
return new MemoizedNumberFormat(locale ?? CONST.LOCALES.DEFAULT, {...options, currency: CONST.CURRENCY.USD}).formatToParts(number);
31+
}
32+
throw e;
33+
}
1734
}
1835

1936
export {format, formatToParts};

0 commit comments

Comments
 (0)