@@ -4,6 +4,7 @@ import IntlStore from '@src/languages/IntlStore';
44import type { OnyxValues } from '@src/ONYXKEYS' ;
55import ONYXKEYS from '@src/ONYXKEYS' ;
66import type { Locale } from '@src/types/onyx' ;
7+ import Log from './Log' ;
78import { format , formatToParts } from './NumberFormatUtils' ;
89
910let 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 {
4788function 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 */
99140function 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 */
183218function 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
202238export {
239+ isValidCurrencyCode ,
240+ sanitizeCurrencyCode ,
241+ resetInvalidCurrencyWarningsForTesting ,
203242 getCurrencyDecimals ,
204243 getCurrencyUnit ,
205244 getLocalizedCurrencySymbol ,
0 commit comments