Payton.Codes

Musings on life, games, and code

Commas and periods make cents

A long time ago (relatively speaking), all prices displayed to users on my company’s website were rendered from their numeric data (like 10.25) into a formatted string in PHP (like £10.25) and then passed along to the client. As our frontend began to have more complex requirements for the way prices were displayed, this was supplemented by a custom-built package for formatting prices on the frontend. These two methods rarely overlapped, so any inconsistencies between them were easily hidden. Recently we started using a great deal more client-side formatting of prices and the limitations of the current setup became problematic. In this post I wanted to talk a bit about how these problems manifested and the work I’ve done to improve the situation.

The two systems

Our frontend uses a function called formatCurrency() (and its more advanced sibling, getCurrencyObject()) to transform a number into a formatted price. The backend uses a Price class which has its own rendering methods. Both systems originally had a hard-coded list of currencies which they used to determine the rules for rendering.

The configuration for both included a flag that determined the order in which to render the currency symbol relative to the number and a symbol property to decide what currency symbol to use. It then had a precision property which it used to determine if a currency should be displayed with decimal places (eg: USD has two decimal places but JPY has none). It also had thousands_separator and decimal_separator properties which described what grouping and decimal characters to use when rendering a number (eg: 1,523.50 vs. 1.523,50).

Problems

There were three big problems with this setup:

  1. There were two sources of truth that had to be maintained manually. If the two configurations did not match (particularly the number of decimal places), we could experience significant user-facing problems. Since people can trivially change one and not the other, it’s easy to get into a case where bugs appear.
  2. The variance between the formatting logic of these two systems means that how prices displayed in one place might not match the formatting of prices displayed in another, sometimes on the same page.
  3. The format of a price should be determined by a combination of currency and locale. While the backend relied on WordPress number localization to do the right thing, in some cases it gets the decimal and grouping characters wrong and its hard-coded format string does not allow the locale to decide where to render the currency symbol. The frontend was even worse because it relied on the currency alone to determine format, ignoring the locale entirely.

A path to standardization

Fortunately, we have some great modern tools at our disposal to format prices in a localized way in both JavaScript and PHP, and they’re built right into the language.

In JavaScript, the Intl.NumberFormat.prototype.format() function has the ability to format localized currencies all on its own without a configuration file. In PHP, the NumberFormatter::formatCurrency() function can do this too.

By changing our frontend and backend to use built-in formatting, we would be able to gain a lot of consistency (both are based on the CLDR formatting standard even if they aren’t quite identical), less maintenance, and more accurate localization.

However, there were several factors to consider to use these functions in our environments. We didn’t actually want 100% localization of prices in all cases. For example, we don’t want to use non-latin characters, we would prefer to use the official symbol for each currency regardless of locale, and we’d like to render $ for USD in en-US but US $ for any other locale. In addition, we needed to keep the same APIs for the formatting functions to prevent a long migration process.

Frontend changes

To begin, we focused on improving the frontend since its formatting had no localization at all. We switched formatCurrency() to use Intl.NumberFormat under the hood and injected the locale from the user’s interface setting. The hard-coded configuration remains but has been stripped down to only a list of currency symbol overrides.

Several features that our version has over using NumberFormat directly are:

  • Uses a forced latin character set to make sure we always display latin numbers for consistency.
  • We override currency codes with a hard-coded list. This is for consistency and so that some currencies seem less confusing when viewed in EN locales, since those are the default locales for many users (eg: always use C$ for CAD instead of CA$ or just $ which are the CLDR standard depending on locale since $ might imply CAD and it might imply USD).
  • We always show US$ for USD when the user’s geolocation is not inside the US. This is important because other currencies use $ for their currency and are surprised sometimes if they are actually charged in USD (which is the default for many users). We can’t safely display US$ for everyone because we’ve found that US users are confused by that style and it decreases confidence in the product.
  • An option to format currency from the currency’s smallest unit (eg: cents in USD, yen in JPY). This is important because doing price math with floating point numbers in code produces unexpected rounding errors, so most currency amounts should be provided and manipulated as integers.

Here’s a simplified version, although it will be rather inefficient since creating a NumberFormat is slow and we need two: one for the number of decimals and one for the formatting. The real version caches the formatter instances.

const currencySymbols = {};

function formatCurrency(
	amount: number,
	currency: string,
	options: CurrencyObjectOptions = {}
): string {
	const locale = options.locale ?? defaultLocale;
	let symbol = currencySymbols[ currency ];
	if ( currency === 'USD' && options.geoLocation !== '' && options.geoLocation !== 'US' ) {
		symbol = 'US$';
	}
	const currencyPrecision =
		new Intl.NumberFormat( locale, { style: 'currency', currency } ).resolvedOptions()
			.maximumFractionDigits ?? 3;
	let numberAsFloat = amount;
	if ( options.isSmallestUnit ) {
		if ( ! Number.isInteger( amount ) ) {
			numberAsFloat = Math.round( amount );
		}
		numberAsFloat = numberAsFloat / 10 ** currencyPrecision;
	}
	const scale = Math.pow( 10, currencyPrecision );
	numberAsFloat = Math.round( numberAsFloat * scale ) / scale;
	const numberFormatOptions: Intl.NumberFormatOptions = {
		style: 'currency',
		currency,
		numberingSystem: 'latn',
		...( options.stripZeros && { trailingZeroDisplay: 'stripIfInteger' } ),
		...( options.signForPositive && { signDisplay: 'exceptZero' } ),
	};
	const formatter = new Intl.NumberFormat( locale, numberFormatOptions );
	const parts = formatter.formatToParts( numberAsFloat );
	return parts.reduce( ( formatted, part ) => {
		switch ( part.type ) {
			case 'currency':
				if ( symbol ) {
					return formatted + symbol;
				}
				return formatted + part.value;
			default:
				return formatted + part.value;
		}
	}, '' );
}

Because prices formatted by the frontend became slightly different (usually better) than backend formatted prices, and they were sometimes rendered on the same page, we updated checkout and purchase management to rely only on JS formatting.

Backend changes

Once the frontend was updated to do most price formatting itself and to use Intl.NumberFormat, most user-facing price localization no longer suffered from the issues mentioned above.

Backend formatted prices now were used pretty much only for emails and internal tools, but it was still surprising when they showed up on the same page as a frontend formatted price with all the differences between them.

Therefore we also modified the formatting functions on the Price class to mimic the locale formatting behavior of the frontend and to respect the user’s interface locale, relying on NumberFormatter::formatCurrency().

Here’s a very simplified version. Just like the JavaScript version, the real version of this code uses caching.

class Price {
	private static array $currency_symbols = [];

	public static function format_currency(
		float $amount,
		string $currency,
		?Price_Format_Options $options = null
	): string {
		$locale = get_locale() ?: 'en';
		$locale_with_extensions = "{$locale}-u-latn-cu-{$currency}";
		$formatter = new NumberFormatter( $locale_with_extensions, NumberFormatter::CURRENCY );
		$currency_config = self::get_localized_currency_configuration( $currency, $options->locale ?? null );
		$currency_symbol = self::get_currency_symbol_for_geolocation( $currency ) ?? $currency_config['symbol'];
		$formatter->setSymbol( NumberFormatter::CURRENCY_SYMBOL, $currency_symbol );
		$currency_config = $currency_config ?? self::get_localized_currency_configuration( $currency, $options->locale ?? null );
		// We cannot use `is_int()` to determine if the amount is an integer
		// because we want to treat `1234.0` as an integer for this purpose.
		$is_number_integer = ! strpos( strval( $amount ), '.' ) !== false;

		if ( $options && $options->is_smallest_unit ) {
			if ( ! $is_number_integer ) {
				$amount = round( $amount );
			}
			$amount = $amount / ( 10 ** $currency_config['decimal'] );
			$is_number_integer = ! strpos( strval( $amount ), '.' ) !== false;
		}

		if ( $options && $options->strip_zeros && $is_number_integer ) {
			$formatter->setAttribute( NumberFormatter::MAX_FRACTION_DIGITS, 0 );
		} else {
			// Reset to default fraction digits for consistency
			$formatter->setAttribute( NumberFormatter::MAX_FRACTION_DIGITS, $currency_config['decimal'] );
			$formatter->setAttribute( NumberFormatter::MIN_FRACTION_DIGITS, $currency_config['decimal'] );
			$formatter->setAttribute( NumberFormatter::FRACTION_DIGITS, $currency_config['decimal'] );
		}

		return $formatter->formatCurrency( $amount, $currency );
	}

	private static function get_localized_currency_configuration( string $currency, ?string $locale = null ): array {
		$locale = $locale ?: get_locale();
		$override_symbol = self::$currency_symbols[ $currency ] ?? null;
		$locale_with_currency = $locale . '@currency=' . $currency;
		$formatter = new NumberFormatter( $locale_with_currency, NumberFormatter::CURRENCY );
		$decimal_separator = $formatter->getSymbol( NumberFormatter::DECIMAL_SEPARATOR_SYMBOL );
		$thousands_separator = $formatter->getSymbol( NumberFormatter::GROUPING_SEPARATOR_SYMBOL );
		$decimal_places = $formatter->getAttribute( NumberFormatter::MAX_FRACTION_DIGITS );
		$currency_symbol = $formatter->getSymbol( NumberFormatter::CURRENCY_SYMBOL );

		// Use hard-coded currency symbol override for consistency except for USD
		// because we want USD users in the US to see `$` and USD users in other
		// countries to see `US $` to avoid ambiguity with other currencies that
		// also use `$`.
		if ( $currency !== 'USD' && ! empty( $override_symbol ) ) {
			$currency_symbol = $override_symbol;
		}

		return [
			'symbol' => $currency_symbol ?: '¤',
			'decimal' => $decimal_places,
			'decimal_separator' => $decimal_separator,
			'thousands_separator' => $thousands_separator,
		];
	}

	private static function get_currency_symbol_for_geolocation(
		string $currency
	): ?string {
		$geo_country_code = $_SERVER['GEOIP_COUNTRY_CODE'] ?? null;
		if ( $currency === 'USD' && $geo_country_code && $geo_country_code !== 'US' ) {
			return 'US$';
		}
		return null;
	}
}

Formatting by locale

After all the above migrations, currency formatting was nicely standardized across the entire system.

For example, with the EUR currency, if your locale is set to en, then you would see €53.40, but if your locale was set to fr, you would see 53,40 €.

Generally we use the symbol for each currency as recommended by CLDR with one notable exception: if you are geolocated outside the US when viewing a USD price, the currency symbol will be US$ to prevent confusing USD with other currencies that use a $ symbol; this prevents users in Argentina from thinking they are paying in ARS when in fact they are paying in USD.

I learned a lot about price formatting and how it’s affected by locale during this process. It was very satisfying to see our pages begin to look right in any locale, and I’m sure it felt a lot better to our users.

Photo by omid armin on Unsplash


Comments

Leave a comment