diff --git a/packages/localize-monetary-amount/README.md b/packages/localize-monetary-amount/README.md new file mode 100644 index 0000000000000..6e27fc619c125 --- /dev/null +++ b/packages/localize-monetary-amount/README.md @@ -0,0 +1,33 @@ +# localize-monetary-amount + +Locale- (language and geo) and currency-aware formatting of exact monetary amounts. Designed for displaying product prices in catalogs, carts, checkout, and receipts. + +What this package does: + * Number and currency localization (separator symbols, grouping conventions) + * Use fixed point arithmetic internally -- no floats or rounding. + +What this package doesn't do: + * Arithmetic on monetary amounts; formatting only + * Conversion between currencies + * Localization of digit symbols -- Hindu-Arabic digits only. (Lifting this restriction is in scope, but currently not implemented) + * Take options. Locale preferences are hardcoded, please submit an issue if a locale looks off. + * Use any external dependencies + +## Example usage + +To format a monetary amount you'll need the following: + * An ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, separated by a hyphen. Examples: `en`, `en-gb`, `fr-be`. This is typically the format browsers use for the user's locale. + * An ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * An integer number of _minor units_. This is the minimal unit of your currency; e.g. cents for USD, yen for JPY. May be positive or negative. + +If the fractional part is zero it is omitted unless the locale prefers otherwise. + +```js +import localizeMonetaryAmount from 'localize-monetary-amount'; + +// Strip zero minor units: +localizeMonetaryAmount( 'en-us', 'USD', 500 ); // '$5' + +// Use non-breaking spaces: +localizeMonetaryAmount( 'fr-ca', 'CAD', 500000 ); // '$5\u00A0000\u00A0CAD' +``` diff --git a/packages/localize-monetary-amount/jest.config.js b/packages/localize-monetary-amount/jest.config.js new file mode 100644 index 0000000000000..2cf7dce3df015 --- /dev/null +++ b/packages/localize-monetary-amount/jest.config.js @@ -0,0 +1 @@ +module.exports = { rootDir: __dirname, testMatch: [ '**/test/**/*.[jt]s?(x)' ] }; diff --git a/packages/localize-monetary-amount/package.json b/packages/localize-monetary-amount/package.json new file mode 100644 index 0000000000000..1422e395c933e --- /dev/null +++ b/packages/localize-monetary-amount/package.json @@ -0,0 +1,38 @@ +{ + "name": "@automattic/localize-monetary-amount", + "version": "1.0.0", + "description": "Batteries included localized currency formatting", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "types/index.d.ts", + "sideEffects": false, + "scripts": { + "clean": "npx rimraf dist types", + "prepublish": "npm run clean", + "prepare": "tsc --project ./tsconfig.json && tsc --project ./tsconfig-cjs.json" + }, + "files": [ + "dist", + "src", + "types" + ], + "keywords": [ + "localization", + "currency", + "automattic" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Automattic/wp-calypso.git", + "directory": "packages/localize-monetary-amount" + }, + "author": "@automattic", + "license": "GPL-2.0-or-later", + "bugs": { + "url": "https://github.com/Automattic/wp-calypso/issues" + }, + "homepage": "https://github.com/Automattic/wp-calypso/tree/master/packages/localized-monetary-amount#readme" +} diff --git a/packages/localize-monetary-amount/src/currency-code.ts b/packages/localize-monetary-amount/src/currency-code.ts new file mode 100644 index 0000000000000..affbfddfa7b00 --- /dev/null +++ b/packages/localize-monetary-amount/src/currency-code.ts @@ -0,0 +1,354 @@ +/** + * Sum type over ISO 4217 currency codes. + * + * @examples 'USD', 'JPY', 'BRL' + */ +export type CurrencyCode = + | 'AED' + | 'AFN' + | 'ALL' + | 'AMD' + | 'ANG' + | 'AOA' + | 'ARS' + | 'AUD' + | 'AWG' + | 'AZN' + | 'BAM' + | 'BBD' + | 'BDT' + | 'BGN' + | 'BHD' + | 'BIF' + | 'BMD' + | 'BND' + | 'BOB' + | 'BRL' + | 'BSD' + | 'BTC' + | 'BTN' + | 'BWP' + | 'BYN' + | 'BZD' + | 'CAD' + | 'CDF' + | 'CHF' + | 'CLF' + | 'CLP' + | 'CNH' + | 'CNY' + | 'COP' + | 'CRC' + | 'CUC' + | 'CUP' + | 'CVE' + | 'CZK' + | 'DJF' + | 'DKK' + | 'DOP' + | 'DZD' + | 'EGP' + | 'ERN' + | 'ETB' + | 'EUR' + | 'FJD' + | 'FKP' + | 'GBP' + | 'GEL' + | 'GGP' + | 'GHS' + | 'GIP' + | 'GMD' + | 'GNF' + | 'GTQ' + | 'GYD' + | 'HKD' + | 'HNL' + | 'HRK' + | 'HTG' + | 'HUF' + | 'IDR' + | 'ILS' + | 'IMP' + | 'INR' + | 'IQD' + | 'IRR' + | 'ISK' + | 'JEP' + | 'JMD' + | 'JOD' + | 'JPY' + | 'KES' + | 'KGS' + | 'KHR' + | 'KID' + | 'KMF' + | 'KPW' + | 'KRW' + | 'KWD' + | 'KYD' + | 'KZT' + | 'LAK' + | 'LBP' + | 'LKR' + | 'LRD' + | 'LSL' + | 'LYD' + | 'MAD' + | 'MDL' + | 'MGA' + | 'MKD' + | 'MMK' + | 'MNT' + | 'MOP' + | 'MRO' + | 'MRU' + | 'MUR' + | 'MVR' + | 'MWK' + | 'MXN' + | 'MYR' + | 'MZN' + | 'NAD' + | 'NGN' + | 'NIO' + | 'NOK' + | 'NPR' + | 'NZD' + | 'OMR' + | 'PAB' + | 'PEN' + | 'PGK' + | 'PHP' + | 'PKR' + | 'PLN' + | 'PRB' + | 'PYG' + | 'QAR' + | 'RON' + | 'RSD' + | 'RUB' + | 'RWF' + | 'SAR' + | 'SBD' + | 'SCR' + | 'SDG' + | 'SEK' + | 'SGD' + | 'SHP' + | 'SLL' + | 'SLS' + | 'SOS' + | 'SRD' + | 'SSP' + | 'STD' + | 'STN' + | 'SVC' + | 'SYP' + | 'SZL' + | 'THB' + | 'TJS' + | 'TMT' + | 'TND' + | 'TOP' + | 'TRY' + | 'TTD' + | 'TVD' + | 'TWD' + | 'TZS' + | 'UAH' + | 'UGX' + | 'USD' + | 'UYU' + | 'UYW' + | 'UZS' + | 'VEF' + | 'VES' + | 'VND' + | 'VUV' + | 'WST' + | 'XAF' + | 'XCD' + | 'XOF' + | 'XPF' + | 'YER' + | 'ZAR' + | 'ZMW'; + +/** + * Number of minor units per major unit. When expressed as a power + * of 10 this is called the exponent. Some currencies have non-integer + * exponents (e.g. Madagascar) so we return the raw number instead. + * Data comes from ISO 4217. + * + * @see https://www.iso.org/iso-4217-currency-codes.html + * + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns Number of minor currency units per major unit + */ +export function minorUnitsPerMajorUnit( currencyCode: CurrencyCode ): 1 | 5 | 100 | 1000 | 10000 { + switch ( currencyCode ) { + // Let's catch the common cases first + case 'AUD': // Australian dollar + case 'BRL': // Brazilian real + case 'CAD': // Canadian dollar + case 'USD': // US dollar + return 100; + + case 'BIF': // Burundian franc + case 'CLP': // Chilean peso + case 'DJF': // Djibouti franc + case 'GNF': // Guinean franc + case 'ISK': // Icelandic króna + case 'JPY': // Yen + case 'KMF': // Comorian franc + case 'KRW': // Won + case 'PYG': // Guarani + case 'RWF': // Rwanda franc + case 'UGX': // Uganda shilling + case 'UYU': // Uruguay peso en unidades indexadas + case 'VUV': // Vatu + case 'VND': // Vietnamese dong + case 'XAF': // CFA Franc BEAC + case 'XOF': // CFA Franc BCEAO + case 'XPF': // CFP Franc + return 1; + + case 'MGA': // Malagasy ariary + case 'MRU': // Mauritanian ouguiya + return 5; + + case 'BHD': // Bahraini dinar + case 'IQD': // Iraqi dinar + case 'JOD': // Jordanian dinar + case 'KWD': // Kuwaiti dinar + case 'LYD': // Libyan dinar + case 'OMR': // Rial Omani + case 'TND': // Tunisian dinar + return 1000; + + case 'CLF': // Unidad de Fomento + case 'UYW': // Unidad Previsional + return 10000; + + default: + return 100; + } +} + +/** + * Detects currencies with globally unique symbols. These are + * nice because they do not require disambiguation. + * + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns + * True if the currency's symbol is unique + */ +export function hasUniqueLocalSymbol( currencyCode: CurrencyCode ): boolean { + const currenciesWithUniqueSymbols: CurrencyCode[] = [ + 'AED', + 'AFN', + 'ALL', + 'AMD', + 'ANG', + 'AOA', + 'AWG', + 'AZN', + 'BAM', + 'BDT', + 'BGN', + 'BHD', + 'BOB', + 'BRL', + 'BTN', + 'BWP', + 'BYN', + 'CHF', + 'CNY', + 'CRC', + 'CZK', + 'DZD', + 'EGP', + 'ERN', + 'ETB', + 'EUR', + 'GEL', + 'GHS', + 'GMD', + 'GTQ', + 'HNL', + 'HRK', + 'HTG', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'IQD', + 'IRR', + 'JOD', + 'JPY', + 'KGS', + 'KHR', + 'KWD', + 'KZT', + 'LAK', + 'LBP', + 'LKR', + 'LSL', + 'LYD', + 'MAD', + 'MDL', + 'MGA', + 'MKD', + 'MMK', + 'MNT', + 'MOP', + 'MRU', + 'MVR', + 'MWK', + 'MYR', + 'MZN', + 'NGN', + 'NIO', + 'NPR', + 'OMR', + 'PAB', + 'PEN', + 'PGK', + 'PHP', + 'PLN', + 'PRB', + 'PYG', + 'QAR', + 'RON', + 'RSD', + 'RUB', + 'SAR', + 'SDG', + 'SLL', + 'SLS', + 'STN', + 'SYP', + 'SZL', + 'THB', + 'TJS', + 'TMT', + 'TND', + 'TOP', + 'TRY', + 'UAH', + 'UZS', + 'VES', + 'VND', + 'VUV', + 'WST', + 'XPF', + 'YER', + 'ZAR', + 'ZMW', + ]; + + return currenciesWithUniqueSymbols.includes( currencyCode ); +} diff --git a/packages/localize-monetary-amount/src/english-currency-name.ts b/packages/localize-monetary-amount/src/english-currency-name.ts new file mode 100644 index 0000000000000..fe2b451068b4b --- /dev/null +++ b/packages/localize-monetary-amount/src/english-currency-name.ts @@ -0,0 +1,347 @@ +/** + * Internal dependencies + */ +import { CurrencyCode } from './currency-code'; + +/** + * Human readable name (in English) for a given currency code. + * For e.g. tooltips on the frontend. + * + * @throws on unrecognized codes + * + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns + * Human readable currency name in English + */ +export function englishCurrencyName( currencyCode: CurrencyCode ): string { + switch ( currencyCode ) { + case 'AED': + return 'United Arab Emirates Dirham'; + case 'AFN': + return 'Afghan Afghani'; + case 'ALL': + return 'Albanian Lek'; + case 'AMD': + return 'Armenian Dram'; + case 'ANG': + return 'Netherlands Antillean Guilder'; + case 'AOA': + return 'Angolan Kwanza'; + case 'ARS': + return 'Argentine Peso'; + case 'AUD': + return 'Australian Dollar'; + case 'AWG': + return 'Aruban Florin'; + case 'AZN': + return 'Azerbaijani Manat'; + case 'BAM': + return 'Bosnia-Herzegovina Convertible Mark'; + case 'BBD': + return 'Barbadian Dollar'; + case 'BDT': + return 'Bangladeshi Taka'; + case 'BGN': + return 'Bulgarian Lev'; + case 'BHD': + return 'Bahraini Dinar'; + case 'BIF': + return 'Burundian Franc'; + case 'BMD': + return 'Bermudan Dollar'; + case 'BND': + return 'Brunei Dollar'; + case 'BOB': + return 'Bolivian Boliviano'; + case 'BRL': + return 'Brazilian Real'; + case 'BSD': + return 'Bahamian Dollar'; + case 'BTC': + return 'Bitcoin'; + case 'BTN': + return 'Bhutanese Ngultrum'; + case 'BWP': + return 'Botswanan Pula'; + case 'BYN': + return 'Belarusian Ruble'; + case 'BZD': + return 'Belize Dollar'; + case 'CAD': + return 'Canadian Dollar'; + case 'CDF': + return 'Congolese Franc'; + case 'CHF': + return 'Swiss Franc'; + case 'CLF': + return 'Chilean Unit of Account (UF)'; + case 'CLP': + return 'Chilean Peso'; + case 'CNH': + return 'Chinese Yuan (Offshore)'; + case 'CNY': + return 'Chinese Yuan'; + case 'COP': + return 'Colombian Peso'; + case 'CRC': + return 'Costa Rican Colón'; + case 'CUC': + return 'Cuban Convertible Peso'; + case 'CUP': + return 'Cuban Peso'; + case 'CVE': + return 'Cape Verdean Escudo'; + case 'CZK': + return 'Czech Republic Koruna'; + case 'DJF': + return 'Djiboutian Franc'; + case 'DKK': + return 'Danish Krone'; + case 'DOP': + return 'Dominican Peso'; + case 'DZD': + return 'Algerian Dinar'; + case 'EGP': + return 'Egyptian Pound'; + case 'ERN': + return 'Eritrean Nakfa'; + case 'ETB': + return 'Ethiopian Birr'; + case 'EUR': + return 'Euro'; + case 'FJD': + return 'Fijian Dollar'; + case 'FKP': + return 'Falkland Islands Pound'; + case 'GBP': + return 'British Pound Sterling'; + case 'GEL': + return 'Georgian Lari'; + case 'GGP': + return 'Guernsey Pound'; + case 'GHS': + return 'Ghanaian Cedi'; + case 'GIP': + return 'Gibraltar Pound'; + case 'GMD': + return 'Gambian Dalasi'; + case 'GNF': + return 'Guinean Franc'; + case 'GTQ': + return 'Guatemalan Quetzal'; + case 'GYD': + return 'Guyanaese Dollar'; + case 'HKD': + return 'Hong Kong Dollar'; + case 'HNL': + return 'Honduran Lempira'; + case 'HRK': + return 'Croatian Kuna'; + case 'HTG': + return 'Haitian Gourde'; + case 'HUF': + return 'Hungarian Forint'; + case 'IDR': + return 'Indonesian Rupiah'; + case 'ILS': + return 'Israeli New Sheqel'; + case 'IMP': + return 'Manx pound'; + case 'INR': + return 'Indian Rupee'; + case 'IQD': + return 'Iraqi Dinar'; + case 'IRR': + return 'Iranian Rial'; + case 'ISK': + return 'Icelandic Króna'; + case 'JEP': + return 'Jersey Pound'; + case 'JMD': + return 'Jamaican Dollar'; + case 'JOD': + return 'Jordanian Dinar'; + case 'JPY': + return 'Japanese Yen'; + case 'KES': + return 'Kenyan Shilling'; + case 'KGS': + return 'Kyrgystani Som'; + case 'KHR': + return 'Cambodian Riel'; + case 'KMF': + return 'Comorian Franc'; + case 'KPW': + return 'North Korean Won'; + case 'KRW': + return 'South Korean Won'; + case 'KWD': + return 'Kuwaiti Dinar'; + case 'KYD': + return 'Cayman Islands Dollar'; + case 'KZT': + return 'Kazakhstani Tenge'; + case 'LAK': + return 'Laotian Kip'; + case 'LBP': + return 'Lebanese Pound'; + case 'LKR': + return 'Sri Lankan Rupee'; + case 'LRD': + return 'Liberian Dollar'; + case 'LSL': + return 'Lesotho Loti'; + case 'LYD': + return 'Libyan Dinar'; + case 'MAD': + return 'Moroccan Dirham'; + case 'MDL': + return 'Moldovan Leu'; + case 'MGA': + return 'Malagasy Ariary'; + case 'MKD': + return 'Macedonian Denar'; + case 'MMK': + return 'Myanma Kyat'; + case 'MNT': + return 'Mongolian Tugrik'; + case 'MOP': + return 'Macanese Pataca'; + case 'MRO': + return 'Mauritanian Ouguiya'; + case 'MUR': + return 'Mauritian Rupee'; + case 'MVR': + return 'Maldivian Rufiyaa'; + case 'MWK': + return 'Malawian Kwacha'; + case 'MXN': + return 'Mexican Peso'; + case 'MYR': + return 'Malaysian Ringgit'; + case 'MZN': + return 'Mozambican Metical'; + case 'NAD': + return 'Namibian Dollar'; + case 'NGN': + return 'Nigerian Naira'; + case 'NIO': + return 'Nicaraguan Córdoba'; + case 'NOK': + return 'Norwegian Krone'; + case 'NPR': + return 'Nepalese Rupee'; + case 'NZD': + return 'New Zealand Dollar'; + case 'OMR': + return 'Omani Rial'; + case 'PAB': + return 'Panamanian Balboa'; + case 'PEN': + return 'Peruvian Nuevo Sol'; + case 'PGK': + return 'Papua New Guinean Kina'; + case 'PHP': + return 'Philippine Peso'; + case 'PKR': + return 'Pakistani Rupee'; + case 'PLN': + return 'Polish Zloty'; + case 'PYG': + return 'Paraguayan Guarani'; + case 'QAR': + return 'Qatari Rial'; + case 'RON': + return 'Romanian Leu'; + case 'RSD': + return 'Serbian Dinar'; + case 'RUB': + return 'Russian Ruble'; + case 'RWF': + return 'Rwandan Franc'; + case 'SAR': + return 'Saudi Riyal'; + case 'SBD': + return 'Solomon Islands Dollar'; + case 'SCR': + return 'Seychellois Rupee'; + case 'SDG': + return 'Sudanese Pound'; + case 'SEK': + return 'Swedish Krona'; + case 'SGD': + return 'Singapore Dollar'; + case 'SHP': + return 'Saint Helena Pound'; + case 'SLL': + return 'Sierra Leonean Leone'; + case 'SOS': + return 'Somali Shilling'; + case 'SRD': + return 'Surinamese Dollar'; + case 'SSP': + return 'South Sudanese Pound'; + case 'STD': + return 'São Tomé and Príncipe Dobra'; + case 'SVC': + return 'Salvadoran Colón'; + case 'SYP': + return 'Syrian Pound'; + case 'SZL': + return 'Swazi Lilangeni'; + case 'THB': + return 'Thai Baht'; + case 'TJS': + return 'Tajikistani Somoni'; + case 'TMT': + return 'Turkmenistani Manat'; + case 'TND': + return 'Tunisian Dinar'; + case 'TOP': + return 'Tongan Paanga'; + case 'TRY': + return 'Turkish Lira'; + case 'TTD': + return 'Trinidad and Tobago Dollar'; + case 'TWD': + return 'New Taiwan Dollar'; + case 'TZS': + return 'Tanzanian Shilling'; + case 'UAH': + return 'Ukrainian Hryvnia'; + case 'UGX': + return 'Ugandan Shilling'; + case 'USD': + return 'United States Dollar'; + case 'UYU': + return 'Uruguayan Peso'; + case 'UZS': + return 'Uzbekistan Som'; + case 'VEF': + return 'Venezuelan Bolívar Fuerte'; + case 'VND': + return 'Vietnamese Dong'; + case 'VUV': + return 'Vanuatu Vatu'; + case 'WST': + return 'Samoan Tala'; + case 'XAF': + return 'CFA Franc BEAC'; + case 'XCD': + return 'East Caribbean Dollar'; + case 'XOF': + return 'CFA Franc BCEAO'; + case 'XPF': + return 'CFP Franc'; + case 'YER': + return 'Yemeni Rial'; + case 'ZAR': + return 'South African Rand'; + case 'ZMW': + return 'Zambian Kwacha'; + + default: + throw new Error( `Currency not supported: ${ currencyCode }` ); + } +} diff --git a/packages/localize-monetary-amount/src/index.ts b/packages/localize-monetary-amount/src/index.ts new file mode 100644 index 0000000000000..526a88e0372ce --- /dev/null +++ b/packages/localize-monetary-amount/src/index.ts @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { localizeMonetaryAmount } from './localize-monetary-amount'; +import { englishCurrencyName } from './english-currency-name'; +import { CurrencyCode } from './currency-code'; + +export { + // Types + CurrencyCode, + // Utilities + localizeMonetaryAmount, + englishCurrencyName, +}; diff --git a/packages/localize-monetary-amount/src/localize-monetary-amount.ts b/packages/localize-monetary-amount/src/localize-monetary-amount.ts new file mode 100644 index 0000000000000..c265266402f86 --- /dev/null +++ b/packages/localize-monetary-amount/src/localize-monetary-amount.ts @@ -0,0 +1,998 @@ +/** + * Internal dependencies + */ +import { CurrencyCode, minorUnitsPerMajorUnit, hasUniqueLocalSymbol } from './currency-code'; +import { + CheckedNumber, + validateInteger, + NonNegativeInteger, + Sign, + signOf, + absoluteValue, +} from './number'; + +/** + * Format a monetary amount according to the custom of a given linguistic + * and geographic locale. + * + * + * THE PROBLEM + * =========== + * + * Idiomatic formatting of monetary amounts depends on (1) the currency, + * (2) the user's preferred language, and sometimes (3) the user's location. + * Several questions have to be answered before rendering an amount as text. + * + * (1) What currency symbol is used? + * (2) If the currency symbol is not unique, how is it disambiguated? + * (e.g. '$' is severely overloaded.) + * (3) What symbol is used for the radix point? + * (this is the '.' in '$1.00') + * (4) What symbol is used to separate digit groups in large integers? + * (this is the ',' in '$1,000') + * (5) How are the digits of large integers grouped for readability? + * (usually in groups of three, but not always!) + * (6) Does the currency symbol appear to the left or the right of the number? + * (e.g. rtl languages) + * + * Answers to all of these can vary across the world, and getting it right is + * an important part of localization. Seeing numbers written in an unfamiliar + * way is bad UX and erodes trust. We can do better! + * + * + * OUR STRATEGY + * ============ + * + * At a high level, this code is based on the following assumption: the single + * most important predictor of a user's preferred currency format is their + * *language*, possibly supplemented by their *country*, and after that their + * chosen *currency*. Here's why. + * + * (1) Formatting customs vary by /country/ in general because trade + * networks are most connected inside of national boundaries. + * (2) However, due to larger network effects (and colonialism) the + * customs tend to be the same across countries that share + * a dominant /language/, and can differ inside countries + * with multiple dominant languages (e.g. Canada or Belgium). + * (3) Because of (2), we can't take for granted that knowing what + * country a user is currently in tells us what kind of numbers + * they expect to see. People move around! + * (4) BUT at the same time knowing the language alone is not enough, + * because of (1). Some countries which share a language use + * different styles (e.g. UK and US). + * + * And so the first argument to this function is an ISO 631-1 language code + * with an optional ISO 3166-1 alpha-2 region code, separated by a hyphen. + * This tends to be the locale format reported in browsers. + * + * The bulk of the implementation is then a giant switch statement on the + * locale. It's not pretty, but it's the clearest way I can think of to break + * up the problem. This associates each locale/currency pair to a /schema/, + * which is a slug representing the localized format. + * + * @see https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + * + * + * GLOSSARY + * ======== + * + * * currency symbol + * This is the customary unit symbol of the currency, like '$' or '€'. + * Note that these may not be unique and so may require disambiguation, + * and because currencies are issued by national governments that can + * depend on the geo locale. Not to be confused with three letter names + * specified in ISO 4217, which are unique. Currencies with unique + * symbols are AWESOME. + * * major unit + * All currencies have a /major unit/; this is "the" unit of the currency. + * For USD it's the dollar, for EUR it's the euro, for JPY it's the yen. + * * minor unit + * Most currencies divide their major unit into some whole number of /minor + * units/; e.g. in USD we have 1 dollar == 100 cents. For some currencies + * the minor unit is no longer in use due to inflation or devaluation; + * this is the case for JPY. In these cases (and JPY is the main one) + * we might as well think of the minor unit as the same as the major unit. + * Regardless of currency, monetary amounts are formatted as an integer + * number of major units with any minor unit remainder as a decimal part. + * * exponent + * For most currencies the number of minor units per major unit is a + * power of ten, either 100 = 10^2 or 1000 = 10^3. In these cases the + * power (2 or 3) is called the /exponent/. HOWEVER note that there are + * some currencies where the number of minor units per major unit is not + * an integer power of 10, so working with the exponent (2 or 3) is harder + * than working with the multiple directly (100 or 1000). This code uses + * the multiple only. + * + * + * NOTES FOR MAINTAINERS + * ===================== + * + * This package will almost certainly require tweaking on a locale-by-locale + * basis. It's intended that this will happen in two places: + * + * * To adjust formatting for a specific locale and currency: + * Look at currencyFormattingSchema(). This is a giant nested switch + * statement that governs the format used for a given locale/currency pair. + * See also the CurrencyFormat type. + * * To add a new format: + * Look at localizeCurrencyWithSchema(). This is where a format slug + * and amount data get translated to an actual string. + * + * + * FURTHER READING + * =============== + * + * Surprisingly there are no standard documents on localized currency formatting, + * but the following resources are helpful: + * + * https://publications.europa.eu/code/en/en-370303.htm + * https://en.wikipedia.org/wiki/ISO_4217 + * https://en.wikipedia.org/wiki/Currency_symbol + * https://en.wikipedia.org/wiki/Decimal_separator + * https://www.thefinancials.com/Default.aspx?SubSectionID=curformat + * https://www.loc.gov/standards/iso639-2/php/English_list.php + * + * @param rawLocaleCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @param amount + * Integer amount in minor currency units. Examples: $1.00 USD and ¥100 JPY + * are both passed as 100. + * @returns Localized monetary amount + */ +export function localizeMonetaryAmount( + rawLocaleCode: LocaleCode< Raw >, + currencyCode: CurrencyCode, + amount: CheckedNumber< Raw > +): LocalizedMonetaryAmount { + // Validate arguments + const localeCode = normalizeLocaleCode( rawLocaleCode ); + const validatedAmount = validateInteger( amount ); + + return localizeCurrencyWithSchema( + signOf( validatedAmount ), + absoluteValue( validatedAmount ), + localeCode, + currencyCode, + currencyFormattingSchema( localeCode, currencyCode ) + ); +} + +/** + * Type alias for localized currency strings + */ +type LocalizedMonetaryAmount = string; + +/** + * Representation of the possible currency schemas + */ +const enum CurrencyFormat { + LocalSymbol_Amount, + LocalSymbol_Amount_Code, +} + +/** + * A monetary amount string should be displayed as a unit with + * no line breaks. We use non-breaking spaces to ensure this. + * + * @type {string} + */ +const NBSP = '\u00A0'; + +/** + * Selects a reified formatting style for the given locale and currency. + * This style is represented by a slug called a /schema/, and consumed by + * localizeCurrencyWithSchema() to construct the formatted string. + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns Currency formatting schema slug + */ +function currencyFormattingSchema( + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode +): CurrencyFormat { + switch ( localeCode ) { + case 'en-us': + // Most common case first + if ( currencyCode === 'USD' ) { + return CurrencyFormat.LocalSymbol_Amount; + } + + if ( hasUniqueLocalSymbol( currencyCode ) ) { + return CurrencyFormat.LocalSymbol_Amount; + } + + switch ( currencyCode ) { + case 'GBP': + return CurrencyFormat.LocalSymbol_Amount; + + default: + return CurrencyFormat.LocalSymbol_Amount_Code; + } + + default: + if ( hasUniqueLocalSymbol( currencyCode ) ) { + return CurrencyFormat.LocalSymbol_Amount; + } + + switch ( currencyCode ) { + case 'GBP': + return CurrencyFormat.LocalSymbol_Amount; + + // Default to generic verbose format + default: + return CurrencyFormat.LocalSymbol_Amount_Code; + } + } +} + +/** + * Localize a monetary amount using the given schema slug. + * + * @param sign + * For distinguishing positive, negative, and zero amounts + * @param amount + * Whole number of minor currency units, cannot be negative + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @param schema + * A slug representing a currency format + * @returns Localized monetary amount + */ +function localizeCurrencyWithSchema( + sign: Sign, + amount: CheckedNumber< NonNegativeInteger >, + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode, + schema: CurrencyFormat +): LocalizedMonetaryAmount { + const currencySymbol = localSymbolForCurrency( localeCode, currencyCode ); + const signSymbol = sign === Sign.IsNegative ? '-' : ''; + + switch ( schema ) { + // $1,000.50 + case CurrencyFormat.LocalSymbol_Amount: + if ( Sign.IsZero === sign ) { + return currencySymbol + '0'; + } + + return ( + signSymbol + currencySymbol + renderAmountWithSeparators( amount, localeCode, currencyCode ) + ); + + // $1,000.50 USD + case CurrencyFormat.LocalSymbol_Amount_Code: + if ( Sign.IsZero === sign ) { + return currencySymbol + '0' + NBSP + currencyCode; + } + + return ( + signSymbol + + currencySymbol + + renderAmountWithSeparators( amount, localeCode, currencyCode ) + + NBSP + + currencyCode + ); + + default: + throw new Error( `Unrecognized currency format ${ schema }` ); + } +} + +/** + * Localize a nonnegative number of minimal currency units. Does not + * include any currency symbols. + * + * @param amount + * Whole number amount in minimal currency units; cannot be negative + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns Localized amount string + */ +function renderAmountWithSeparators( + amount: CheckedNumber< NonNegativeInteger >, + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode +): string { + const { integerPart, fractionalPart } = digitGroupsOfAmountForCurrency( + localeCode, + currencyCode, + amount + ); + const { groupSeparator } = separatorsForLocale( localeCode ); + const base = minorUnitsPerMajorUnit( currencyCode ); + + // Currencies with no minor unit + if ( 1 === base ) { + return integerPart.join( groupSeparator ); + } + + return integerPart.join( groupSeparator ) + fractionalPart; +} + +/** + * Represents the digit groups of a formatted number. The + * integer part is sorted from most to least significant. + */ +interface DigitGrouping { + integerPart: string[]; + fractionalPart: string; +} + +/** + * Separate an amount into its fractional part and grouped digits + * of the integer part. + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @param amount + * Whole number amount in minimal currency units; cannot be negative + * @returns + * Digit groups; the integer part is sorted from most to least significant + */ +function digitGroupsOfAmountForCurrency( + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode, + amount: CheckedNumber< NonNegativeInteger > +): DigitGrouping { + // Zero money is a special case. + if ( 0 === amount ) { + return { + integerPart: [ '0' ], + fractionalPart: minorUnitsAsDecimalForCurrency( localeCode, currencyCode, 0 ), + }; + } + + const base = minorUnitsPerMajorUnit( currencyCode ); + + // Currencies with no minor unit are a special case. + if ( 1 === base ) { + return { + integerPart: groupDigits( localeCode, amount ), + fractionalPart: '', + }; + } + + const fractionalPart = minorUnitsAsDecimalForCurrency( localeCode, currencyCode, amount % base ); + const majorUnitAmount = Math.floor( amount / base ); + + return { + integerPart: groupDigits( localeCode, majorUnitAmount ), + fractionalPart: fractionalPart, + }; +} + +/** + * Computes the digit groups of an integer as an array of strings from most + * to least significant. This depends on the locale, as customs for grouping + * digits differ around the world. + * + * @examples + * - US: 100,000,000 + * - India: 10,00,00,000 + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. Must be lower case. + * @param amount + * Number to decompose; must be nonnegative + * @returns + * Digit groups as strings, from most to least significant + */ +function groupDigits( + localeCode: LocaleCode< Normalized >, + amount: CheckedNumber< NonNegativeInteger > +): string[] { + // Accumulating recursive function that splits digits into + // groups of a fixed size. Interior groups are zero-padded. + function groupDigitsAccum( + localAmount: number, + localDigits: string[], + numDigitsPerGroup: number + ) { + const base = 10 ** numDigitsPerGroup; + + if ( localAmount < base ) { + return [ localAmount.toString() ].concat( localDigits ); + } + + const nextGroup = localAmount % base; + const remaining = Math.floor( localAmount / base ); + + return groupDigitsAccum( + remaining, + [ nextGroup.toString().padStart( numDigitsPerGroup, '0' ) ].concat( localDigits ), + numDigitsPerGroup + ); + } + + switch ( localeCode ) { + // Locales with "2*3" style digit groups + case 'bn': // Bengali + case 'en-in': // English - India + case 'en-pk': // English - Pakistan + case 'hi': // Hindi + case 'in': // India + case 'my': // Burmese + case 'ne': // Nepali + case 'pk': // Pakistan + case 'si': // Sinhala + case 'ta': // Tamil + case 'ur': // Urdu + if ( amount < 1000 ) { + return [ amount.toString() ]; + } + + return groupDigitsAccum( + Math.floor( amount / 1000 ), + [ ( amount % 1000 ).toString().padStart( 3, '0' ) ], + 2 + ); + + // Locales with "3*" style digit groups + default: + return groupDigitsAccum( amount, [], 3 ); + } +} + +/** + * Formats fractional currency units with the radix symbol. + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @param amount + * Number of minor currency units. Must be positive, and must be in + * the range [0 .. minorUnitsPerMajorUnit - 1] + * @returns + * Fractional currency string with radix symbol + */ +function minorUnitsAsDecimalForCurrency( + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode, + amount: CheckedNumber< NonNegativeInteger > +): string { + const radixSymbol = separatorsForLocale( localeCode ).decimalSeparator; + + switch ( currencyCode ) { + // Let's catch the common cases first + case 'AUD': // Australian dollar + case 'BRL': // Brazilian real + case 'CAD': // Canadian dollar + case 'USD': // US dollar + if ( amount < 0 || 100 <= amount ) { + throw new Error( + `Minor units should be between 0 and 99 inclusive; ${ amount } is invalid` + ); + } + if ( amount === 0 ) { + return ''; + } + return radixSymbol + amount.toString().padStart( 2, '0' ); + + // Currencies with no minor unit + case 'BIF': // Burundian franc + case 'CLP': // Chilean peso + case 'DJF': // Djibouti franc + case 'GNF': // Guinean franc + case 'ISK': // Icelandic króna + case 'JPY': // Yen + case 'KMF': // Comorian franc + case 'KRW': // Won + case 'PYG': // Guarani + case 'RWF': // Rwanda franc + case 'UGX': // Uganda shilling + case 'UYU': // Uruguay peso en unidades indexadas + case 'VUV': // Vatu + case 'VND': // Vietnamese dong + case 'XAF': // CFA Franc BEAC + case 'XOF': // CFA Franc BCEAO + case 'XPF': // CFP Franc + throw new Error( `Currency does not have a minor unit: ${ currencyCode }` ); + + // 10000 minor units per major unit + case 'CLF': // Unidad de Fomento + case 'UYW': // Unidad Previsional + if ( amount < 0 || 10000 <= amount ) { + throw new Error( + `Minor units should be between 0 and 9999 inclusive; ${ amount } is invalid` + ); + } + if ( amount === 0 ) { + return ''; + } + return radixSymbol + amount.toString().padStart( 4, '0' ); + + // 1000 minor units per major unit + case 'BHD': // Bahraini dinar + case 'IQD': // Iraqi dinar + case 'JOD': // Jordanian dinar + case 'KWD': // Kuwaiti dinar + case 'LYD': // Libyan dinar + case 'OMR': // Rial Omani + case 'TND': // Tunisian dinar + if ( amount < 0 || 1000 <= amount ) { + throw new Error( + `Minor units should be between 0 and 999 inclusive; ${ amount } is invalid` + ); + } + if ( amount === 0 ) { + return ''; + } + return radixSymbol + amount.toString().padStart( 3, '0' ); + + // 5 minor units per major unit (!!!) + case 'MGA': // Malagasy ariary + case 'MRU': // Mauritanian ouguiya + if ( amount < 0 || 5 <= amount ) { + throw new Error( + `Minor units should be between 0 and 4 inclusive; ${ amount } is invalid` + ); + } + if ( amount === 0 ) { + return ''; + } + return radixSymbol + ( amount * 20 ).toString(); + + // Otherwise assume 100 minor units per major unit + default: + if ( amount < 0 || 100 <= amount ) { + throw new Error( + `Minor units should be between 0 and 99 inclusive; ${ amount } is invalid` + ); + } + if ( amount === 0 ) { + return ''; + } + return radixSymbol + amount.toString().padStart( 2, '0' ); + } +} + +/** + * Local currency symbol as used in its issuing jurisdiction. May not be unique. + * Note that some symbols vary by language. + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. Must be lower case. + * @param currencyCode + * ISO 4217 currency code. Examples: 'USD', 'JPY', 'BRL'. + * @returns Local currency symbol. May not be unique. + */ +function localSymbolForCurrency( + localeCode: LocaleCode< Normalized >, + currencyCode: CurrencyCode +): string { + switch ( currencyCode ) { + // Let's catch the most common cases early + case 'USD': // United States dollar + case 'AUD': // Australian dollar + case 'CAD': // Canadian dollar + return '$'; + + case 'EUR': // Euro + return '€'; + + case 'GBP': // British pound + return '£'; + + // $ + case 'ARS': // Argentine peso + case 'BBD': // Barbadian dollar + case 'BMD': // Bermudian dollar + case 'BND': // Brunei dollar + case 'BZD': // Belize dollar + case 'BSD': // Bahamian dollar + case 'CLP': // Chilean peso + case 'COP': // Colombian peso + case 'KYD': // Cayman Islands dollar + case 'CUC': // Cuban convertible peso + case 'CUP': // Cuban peso + case 'CVE': // Cape Verdean escudo + case 'DOP': // Dominican peso + case 'FJD': // Fijian dollar + case 'GYD': // Guyanese dollar + case 'HKD': // Hong Kong dollar + case 'KID': // Kiribati dollar + case 'JMD': // Jamaican dollar + case 'LRD': // Liberian dollar + case 'MXN': // Mexican peso + case 'NAD': // Namibian dollar + case 'NZD': // New Zealand dollar + case 'SBD': // Solomon Islands dollar + case 'SGD': // Singapore dollar + case 'SRD': // Surinamese dollar + case 'TTD': // Trinidad and Tobago dollar + case 'TVD': // Tuvaluan dollar + case 'TWD': // New Taiwan dollar + case 'UYU': // Uruguayan peso + case 'XCD': // Eastern Caribbean dollar + return '$'; + + // kr + case 'DKK': // Danish krone + case 'ISK': // Icelandic króna + case 'NOK': // Norwegian krone + case 'SEK': // Swedish krona + return 'kr'; + + // Fr + case 'BIF': // Burundian franc + case 'CDF': // Congolese franc + case 'DJF': // Djiboutian franc + case 'GNF': // Guinean franc + case 'KMF': // Comorian franc + case 'RWF': // Rwandan franc + case 'XAF': // Central African CFA franc + case 'XOF': // West African CFA franc + return 'Fr'; + + // £ + case 'IMP': // Manx pound (Isle of Man) + case 'JEP': // Jersey pound + case 'FKP': // Falkland Islands pound + case 'GGP': // Guernsey pound + case 'GIP': // Gibraltar pound + case 'SHP': // Saint Helena pound + case 'SSP': // South Sudanese pound + return '£'; + + // Sh + case 'KES': // Kenyan shilling + case 'SOS': // Somali shilling + case 'TZS': // Tanzanian shilling + case 'UGX': // Ugandan shilling + return 'Sh'; + + // ₨ + case 'MUR': // Mauritian rupee + case 'PKR': // Pakistani rupee + case 'SCR': // Seychellois rupee + return '₨'; + + // ₩ + case 'KPW': // North Korean won + case 'KRW': // South Korean won + return '₩'; + + // Unique currency symbols + case 'AED': // United Arab Emirates dirham + return 'د.إ'; + case 'AFN': // Afghan afghani + return '؋'; + case 'ALL': // Albanian lek + return 'L'; + case 'AMD': // Armenian dram + return '֏'; + case 'ANG': // Netherlands Antillean guilder + return 'ƒ'; + case 'AOA': // Angolan kwanza + return 'Kz'; + case 'AWG': // Aruban florin + return 'ƒ'; + case 'AZN': // Azerbaihani manat + return '₼'; + case 'BAM': // Bosnia and Herzegovina convertible mark + return 'KM'; + case 'BDT': // Bangladeshi taka + return '৳ '; + case 'BGN': // Bulgarian lev + return 'лв.'; + case 'BHD': // Bahraini dinar + return '.د.ب'; + case 'BOB': // Boliviano + return 'Bs.'; + case 'BRL': // Brazilian real + return 'R$'; + case 'BTN': // Bhutanese ngultrum + return 'Nu.'; + case 'BWP': // Botswana pula + return 'P'; + case 'BYN': // Belarusian ruble + return 'Br'; + case 'CHF': // Swiss franc + return 'Fr.'; + case 'CNY': // Chinese yuan + return '元'; + case 'CRC': // Costa Rican colón + return '₡'; + case 'CZK': // Czech koruna + return 'Kč'; + case 'DZD': // Algerian dinar + return 'د.ج'; + case 'EGP': // Egyptian pound + switch ( localeCode ) { + case 'ar-eg': + return 'ج.م'; + default: + return 'E£'; + } + case 'ERN': // Eritrean nakfa + return 'Nfk'; + case 'ETB': // Ethiopian birr + return 'Br'; + case 'GEL': // Geolrian lari + return '₾'; + case 'GHS': // Ghanaian cedi + return '₵'; + case 'GMD': // Gambian dalasi + return 'D'; + case 'GTQ': // Guatemalan quetzal + return 'Q'; + case 'HNL': // Honduran lempira + return 'L'; + case 'HRK': // Croatian kuna + return 'kn'; + case 'HTG': // Haitian gourde + return 'G'; + case 'HUF': // Hungarian forint + return 'Ft'; + case 'IDR': // Indonesian rupiah + return 'Rp'; + case 'ILS': // Israeli shekel + return '₪'; + case 'INR': // Indian rupee + return '₹'; + case 'IQD': // Iraqi dinar + return 'ع.د'; + case 'IRR': // Iranian dinar + return '﷼'; + case 'JOD': // Jordanian dinar + return 'د.ا'; + case 'JPY': // Japanese yen + return '¥'; + case 'KGS': // Kyrgyzstani som + return 'с'; + case 'KHR': // Cambodian riel + return '៛'; + case 'KWD': // Kuwaiti dinar + return 'د.ك'; + case 'KZT': // Kazakhstani tenge + return '₸'; + case 'LAK': // Lao kip + return '₭'; + case 'LBP': // Lebanese pound + return 'ل.ل'; + case 'LKR': // Sri Lankan rupee + switch ( localeCode ) { + case 'si': // Sinhala + return 'රු'; + case 'ta': // Tamil + return 'ரூ'; + default: + return 'Rs.'; + } + case 'LSL': // Lesotho loti + return 'L'; + case 'LYD': // Libyan dinar + return 'ل.د'; + case 'MAD': // Moroccan dirham + return 'د.م.'; + case 'MDL': // Moldovan leu + return 'L'; + case 'MGA': // Malagasy ariary + return 'Ar'; + case 'MKD': // Macedonian denar + return 'ден'; + case 'MMK': // Burmese kyat + return 'Ks'; + case 'MNT': // Mongolian tögrög + return '₮'; + case 'MOP': // Macanese pataca + return 'P'; + case 'MRU': // Mauritanian ouguiya + return 'UM'; + case 'MVR': // Maldivian rufiyaa + return '.ރ'; + case 'MWK': // Malawian kwacha + return 'MK'; + case 'MYR': // Malaysian ringgit + return 'RM'; + case 'MZN': // Mozambican metical + return 'MT'; + case 'NGN': // Nigerian naira + return '₦'; + case 'NIO': // Nicaraguan córdoba + return 'C$'; + case 'NPR': // Nepalese rupee + return 'रू'; + case 'OMR': // Omani rial + return 'ر.ع.'; + case 'PAB': // Panamanian balboa + return 'B/.'; + case 'PEN': // Perfuvian sol + return 'S/'; + case 'PGK': // Papua New Guinean kina + return 'K'; + case 'PHP': // Philippine peso + return '₱'; + case 'PLN': // Polish złoty + return 'zł'; + case 'PRB': // Transnistrian ruble + return 'p.'; + case 'PYG': // Paraguayan guarani + return '₲'; + case 'QAR': // Qatari riyal + return 'ر.ق'; + case 'RON': // Romanian leu + return 'lei'; + case 'RSD': // Serbian dinar + return 'дин.'; + case 'RUB': // Russian ruble, also Pokédollar + return '₽'; + case 'SAR': // Saudi riyal + return 'ر.س'; + case 'SDG': // Sudanese pound + return 'ج.س.'; + case 'SLL': // Sierra Leonean leone + return 'Le'; + case 'SLS': // Somaliland shilling + return 'Sl'; + case 'STN': // São Tomé and Príncipe dobra + return 'Db'; + case 'SYP': // Syrian pound + return '£S'; + case 'SZL': // Swazi lilangeni + return 'L'; + case 'THB': // Thai baht + return '฿'; + case 'TJS': // Tajikistani somoni + return 'SM'; + case 'TMT': // Turkmenistan manat + return 'm'; + case 'TND': // Tunisian dinar + return 'د.ت'; + case 'TOP': // Tongan pa'anga + return 'T$'; + case 'TRY': // Turkish lira + return '₺'; + case 'UAH': // Ukrainian hryvnia + return '₴'; + case 'UZS': // Uzbekistani so'm + return 'сўм'; + case 'VES': // Venezuelan bolívar + return 'Bs.S.'; + case 'VND': // Vietnamese đồng + return '₫'; + case 'VUV': // Vanuatu vatu + return 'Vt'; + case 'WST': // Samoan tālā + return 'T'; + case 'XPF': // CFP franc + return '₣'; + case 'YER': // Yemeni rial + return '﷼'; + case 'ZAR': // South African rand + return 'R'; + case 'ZMW': // Zambian kwacha + return 'ZK'; + + default: + throw new Error( `Symbol for currency code not defined: ${ currencyCode }` ); + } +} + +/** + * Type representing a digit separator custom. + * + * * `decimalSeparator` is the symbol used for the radix point + * * `groupSeparator` is the symbol used to demarcate digit groups + * + * @see separatorsForLocale() + */ +interface DigitSeparators { + decimalSeparator: string; + groupSeparator: string; +} + +/** + * Customary radix point and group separators in a given locale. Note + * that we're using non-breaking spaces. + * + * @param localeCode + * ISO 631-1 language code with an optional ISO 3166-1 alpha-2 region code, + * separated by a hyphen. Examples: 'en', 'en-gb', 'fr-be'. + * @returns Decimal and group separators + */ +function separatorsForLocale( localeCode: LocaleCode< Normalized > ): DigitSeparators { + switch ( localeCode ) { + // 10,000.00 + case 'en-hk': // English - Hong Kong + case 'en-ie': // English - Ireland + case 'en-gb': // English - United Kingdom + case 'en-nz': // English - New Zealand + case 'en-us': // English - United States + case 'es-mx': // Spanish - Mexico + case 'hi': // Hindi + case 'he': // Hebrew + case 'ja': // Japanese + case 'ko': // Korean + return { decimalSeparator: '.', groupSeparator: ',' }; + + // 10 000.00 + case 'en-au': // English - Australia + case 'en-ca': // Canada + return { decimalSeparator: '.', groupSeparator: NBSP }; + + // 10 000,00 + case 'af': // Afrikaans + case 'cs': // Czech + case 'en-za': // English - South Africa + case 'et': // Estonian + case 'fi': // Finnish + case 'fr': // French + case 'fr-be': // French - Belgium + case 'fr-ca': // French - Canada + case 'fr-ch': // French - Switzerland + case 'fr-lu': // French - Luxembourg + case 'hu': // Hungarian + case 'no': // Norwegian + case 'nb': // Norwegian (Bokmål) + case 'nn': // Norwegian (Nynorsk) + return { decimalSeparator: ',', groupSeparator: NBSP }; + + // 10.000,00 + case 'es-ar': // Spanish - Argentina + case 'de': // German + case 'de-at': // German - Austria + case 'pt-br': // Portugese - Brazil + return { decimalSeparator: ',', groupSeparator: '.' }; + + // 10.000$00 + case 'pt-cv': // Portugese - Cape Verde + return { decimalSeparator: '$', groupSeparator: '.' }; + + // 10,000.00 + default: + return { decimalSeparator: '.', groupSeparator: ',' }; + } +} + +/** + * Type alias for ISO 631-1 language codes with an optional + * ISO 3166-1 alpha-2 region code, separated by a hyphen. + * + * Note the phantom type parameter. Like the CurrencyCode type, + * we want to normalize locale codes to a unique representation, + * but unlike CurrencyCode it is not feasible to enumerate + * all the possible values as a sum. Instead, our code is written + * against the LocaleCode type, which can only be + * produced by `normalizeLocaleCode` (which should be called + * exactly once and not exported from this module). + * + * @examples 'en', 'en-gb', 'fr-be' + */ +export type LocaleCode< a > = string; + +export type Raw = void; +type Normalized = void; + +/** + * Normalize a raw locale code. Do not export this. + * + * @param rawLocaleCode + * Locale code string as returned by the browser. + * @returns + * Normalized locale code (all lower case). + */ +function normalizeLocaleCode( rawLocaleCode: LocaleCode< Raw > ): LocaleCode< Normalized > { + return rawLocaleCode.toLowerCase(); +} diff --git a/packages/localize-monetary-amount/src/number.ts b/packages/localize-monetary-amount/src/number.ts new file mode 100644 index 0000000000000..2f6c51f513dd7 --- /dev/null +++ b/packages/localize-monetary-amount/src/number.ts @@ -0,0 +1,45 @@ +/** + * Tagged type for validated integers and natural numbers. + */ +export type CheckedNumber< a > = number; + +/** + * Type tags for verified integers and natural numbers. + * + * NOTE: values of type CheckedNumber< Integer > or + * CheckedNumber< NonNegativeInteger > should _only_ be created + * by validateInteger and absoluteValue, respectively. + */ +type Integer = void; +export type NonNegativeInteger = void; + +export function validateInteger( num: CheckedNumber< any > ): CheckedNumber< Integer > { + if ( ! ( typeof num === 'number' && Number.isInteger( num ) ) ) { + throw new Error( `Expected an integer, but got ${ num }.` ); + } + return num; +} + +export function absoluteValue( + num: CheckedNumber< Integer > +): CheckedNumber< NonNegativeInteger > { + return Math.abs( num ); +} + +export const enum Sign { + IsPositive, + IsZero, + IsNegative, +} + +export function signOf( num: CheckedNumber< any > ): Sign { + if ( Math.sign( num ) < 0 ) { + return Sign.IsNegative; + } + + if ( Math.sign( num ) > 0 ) { + return Sign.IsPositive; + } + + return Sign.IsZero; +} diff --git a/packages/localize-monetary-amount/test/localize-monetary-amount.js b/packages/localize-monetary-amount/test/localize-monetary-amount.js new file mode 100644 index 0000000000000..218fd3c0a91df --- /dev/null +++ b/packages/localize-monetary-amount/test/localize-monetary-amount.js @@ -0,0 +1,161 @@ +/** + * Internal dependencies + */ +import { localizeMonetaryAmount } from '../src/index'; + +describe( 'localizeMonetaryAmount', function() { + describe( 'when locale is en-us', function() { + test.each` + currency | amount | output + ${'USD'} | ${0} | ${'$0'} + ${'USD'} | ${5} | ${'$0.05'} + ${'USD'} | ${50} | ${'$0.50'} + ${'USD'} | ${500} | ${'$5'} + ${'USD'} | ${1010} | ${'$10.10'} + ${'USD'} | ${1337} | ${'$13.37'} + ${'USD'} | ${5000} | ${'$50'} + ${'USD'} | ${50000} | ${'$500'} + ${'USD'} | ${500000} | ${'$5,000'} + ${'USD'} | ${5000000} | ${'$50,000'} + ${'USD'} | ${50000000} | ${'$500,000'} + ${'USD'} | ${500000000} | ${'$5,000,000'} + ${'USD'} | ${-500000} | ${'-$5,000'} + ${'GBP'} | ${123456} | ${'£1,234.56'} + ${'JPY'} | ${123456} | ${'¥123,456'} + ${'EUR'} | ${123456} | ${'€1,234.56'} + ${'CAD'} | ${123456} | ${'$1,234.56\u00A0CAD'} + `( 'en-us/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'en-us', currency, amount ) ).toBe( output ); + } ); + } ); + + describe( 'when locale is en-ca', function() { + test.each` + currency | amount | output + ${'CAD'} | ${0} | ${'$0\u00A0CAD'} + ${'CAD'} | ${5} | ${'$0.05\u00A0CAD'} + ${'CAD'} | ${50} | ${'$0.50\u00A0CAD'} + ${'CAD'} | ${500} | ${'$5\u00A0CAD'} + ${'CAD'} | ${1010} | ${'$10.10\u00A0CAD'} + ${'CAD'} | ${1337} | ${'$13.37\u00A0CAD'} + ${'CAD'} | ${5000} | ${'$50\u00A0CAD'} + ${'CAD'} | ${50000} | ${'$500\u00A0CAD'} + ${'CAD'} | ${500000} | ${'$5\u00A0000\u00A0CAD'} + ${'CAD'} | ${5000000} | ${'$50\u00A0000\u00A0CAD'} + ${'CAD'} | ${50000000} | ${'$500\u00A0000\u00A0CAD'} + ${'CAD'} | ${500000000} | ${'$5\u00A0000\u00A0000\u00A0CAD'} + ${'CAD'} | ${-500000} | ${'-$5\u00A0000\u00A0CAD'} + ${'GBP'} | ${123456} | ${'£1\u00A0234.56'} + ${'JPY'} | ${123456} | ${'¥123\u00A0456'} + ${'USD'} | ${123456} | ${'$1\u00A0234.56\u00A0USD'} + ${'EUR'} | ${123456} | ${'€1\u00A0234.56'} + ${'AUD'} | ${123456} | ${'$1\u00A0234.56\u00A0AUD'} + `( 'en-ca/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'en-ca', currency, amount ) ).toBe( output ); + } ); + } ); + + describe( 'when locale is fr-ca', function() { + test.each` + currency | amount | output + ${'CAD'} | ${0} | ${'$0\u00A0CAD'} + ${'CAD'} | ${5} | ${'$0,05\u00A0CAD'} + ${'CAD'} | ${50} | ${'$0,50\u00A0CAD'} + ${'CAD'} | ${500} | ${'$5\u00A0CAD'} + ${'CAD'} | ${1010} | ${'$10,10\u00A0CAD'} + ${'CAD'} | ${1337} | ${'$13,37\u00A0CAD'} + ${'CAD'} | ${5000} | ${'$50\u00A0CAD'} + ${'CAD'} | ${50000} | ${'$500\u00A0CAD'} + ${'CAD'} | ${500000} | ${'$5\u00A0000\u00A0CAD'} + ${'CAD'} | ${5000000} | ${'$50\u00A0000\u00A0CAD'} + ${'CAD'} | ${50000000} | ${'$500\u00A0000\u00A0CAD'} + ${'CAD'} | ${500000000} | ${'$5\u00A0000\u00A0000\u00A0CAD'} + ${'CAD'} | ${-500000} | ${'-$5\u00A0000\u00A0CAD'} + ${'GBP'} | ${123456} | ${'£1\u00A0234,56'} + ${'JPY'} | ${123456} | ${'¥123\u00A0456'} + ${'USD'} | ${123456} | ${'$1\u00A0234,56\u00A0USD'} + ${'EUR'} | ${123456} | ${'€1\u00A0234,56'} + ${'AUD'} | ${123456} | ${'$1\u00A0234,56\u00A0AUD'} + `( 'fr-ca/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'fr-ca', currency, amount ) ).toBe( output ); + } ); + } ); + + describe( 'when locale is en-au', function() { + test.each` + currency | amount | output + ${'AUD'} | ${0} | ${'$0\u00A0AUD'} + ${'AUD'} | ${5} | ${'$0.05\u00A0AUD'} + ${'AUD'} | ${50} | ${'$0.50\u00A0AUD'} + ${'AUD'} | ${500} | ${'$5\u00A0AUD'} + ${'AUD'} | ${1010} | ${'$10.10\u00A0AUD'} + ${'AUD'} | ${1337} | ${'$13.37\u00A0AUD'} + ${'AUD'} | ${5000} | ${'$50\u00A0AUD'} + ${'AUD'} | ${50000} | ${'$500\u00A0AUD'} + ${'AUD'} | ${500000} | ${'$5\u00A0000\u00A0AUD'} + ${'AUD'} | ${5000000} | ${'$50\u00A0000\u00A0AUD'} + ${'AUD'} | ${50000000} | ${'$500\u00A0000\u00A0AUD'} + ${'AUD'} | ${500000000} | ${'$5\u00A0000\u00A0000\u00A0AUD'} + ${'AUD'} | ${-500000} | ${'-$5\u00A0000\u00A0AUD'} + ${'GBP'} | ${123456} | ${'£1\u00A0234.56'} + ${'JPY'} | ${123456} | ${'¥123\u00A0456'} + ${'USD'} | ${123456} | ${'$1\u00A0234.56\u00A0USD'} + ${'EUR'} | ${123456} | ${'€1\u00A0234.56'} + ${'CAD'} | ${123456} | ${'$1\u00A0234.56\u00A0CAD'} + `( 'en-au/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'en-au', currency, amount ) ).toBe( output ); + } ); + } ); + + describe( 'when locale is en-in', function() { + test.each` + currency | amount | output + ${'INR'} | ${0} | ${'₹0'} + ${'INR'} | ${5} | ${'₹0.05'} + ${'INR'} | ${50} | ${'₹0.50'} + ${'INR'} | ${500} | ${'₹5'} + ${'INR'} | ${1010} | ${'₹10.10'} + ${'INR'} | ${1337} | ${'₹13.37'} + ${'INR'} | ${5000} | ${'₹50'} + ${'INR'} | ${50000} | ${'₹500'} + ${'INR'} | ${500000} | ${'₹5,000'} + ${'INR'} | ${5000000} | ${'₹50,000'} + ${'INR'} | ${50000000} | ${'₹5,00,000'} + ${'INR'} | ${500000000} | ${'₹50,00,000'} + ${'INR'} | ${-500000} | ${'-₹5,000'} + ${'GBP'} | ${123456789} | ${'£12,34,567.89'} + ${'JPY'} | ${123456789} | ${'¥12,34,56,789'} + ${'EUR'} | ${123456789} | ${'€12,34,567.89'} + ${'USD'} | ${123456789} | ${'$12,34,567.89\u00A0USD'} + ${'CAD'} | ${123456789} | ${'$12,34,567.89\u00A0CAD'} + `( 'en-in/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'en-in', currency, amount ) ).toBe( output ); + } ); + } ); + + describe( 'when locale is de', function() { + test.each` + currency | amount | output + ${'EUR'} | ${0} | ${'€0'} + ${'EUR'} | ${5} | ${'€0,05'} + ${'EUR'} | ${50} | ${'€0,50'} + ${'EUR'} | ${500} | ${'€5'} + ${'EUR'} | ${1010} | ${'€10,10'} + ${'EUR'} | ${1337} | ${'€13,37'} + ${'EUR'} | ${5000} | ${'€50'} + ${'EUR'} | ${50000} | ${'€500'} + ${'EUR'} | ${500000} | ${'€5.000'} + ${'EUR'} | ${5000000} | ${'€50.000'} + ${'EUR'} | ${50000000} | ${'€500.000'} + ${'EUR'} | ${500000000} | ${'€5.000.000'} + ${'EUR'} | ${-500000} | ${'-€5.000'} + ${'GBP'} | ${123456} | ${'£1.234,56'} + ${'JPY'} | ${123456} | ${'¥123.456'} + ${'USD'} | ${123456} | ${'$1.234,56\u00A0USD'} + ${'AUD'} | ${123456} | ${'$1.234,56\u00A0AUD'} + ${'CAD'} | ${123456} | ${'$1.234,56\u00A0CAD'} + `( 'de/$currency $amount => $output', ( { currency, amount, output } ) => { + expect( localizeMonetaryAmount( 'de', currency, amount ) ).toBe( output ); + } ); + } ); +} ); diff --git a/packages/localize-monetary-amount/tsconfig-cjs.json b/packages/localize-monetary-amount/tsconfig-cjs.json new file mode 100644 index 0000000000000..81d287d403fd6 --- /dev/null +++ b/packages/localize-monetary-amount/tsconfig-cjs.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "outDir": "./dist/cjs", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "moduleResolution": "node", + "esModuleInterop": true + }, + "include": [ "src/**/*" ], + "exclude": [ "**/test/**/*", "**/docs/**/*" ] +} diff --git a/packages/localize-monetary-amount/tsconfig.json b/packages/localize-monetary-amount/tsconfig.json new file mode 100644 index 0000000000000..5aa8096d87d8a --- /dev/null +++ b/packages/localize-monetary-amount/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "es2015", + "declaration": true, + "declarationDir": "types", + "outDir": "./dist/esm", + + "strict": true, + "noImplicitAny": false, + + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "moduleResolution": "node", + "esModuleInterop": true + }, + "include": [ "src/**/*" ], + "exclude": [ "**/test/**/*", "**/docs/**/*" ] +}