Множественные валюты в одном приложении

Международные веб-приложения редко работают только с одной валютой. Интернет-магазины, CRM-системы, SaaS-платформы, финансовые панели, системы аналитики и маркетплейсы одновременно отображают цены в долларах, евро, тенге, иенах, фунтах и десятках других валют. Простого форматирования чисел недостаточно: каждая локаль использует собственные правила отображения символов, разделителей, порядка записи и округления.

Библиотека Globalize строится поверх стандарта CLDR (Common Locale Data Repository) и предоставляет полноценную систему локализации чисел, валют, дат и сообщений.

Основная задача при работе с несколькими валютами — разделить:

  • внутреннее хранение данных;
  • валютную бизнес-логику;
  • пользовательское отображение.

На практике:

  • сервер хранит суммы в минимальных единицах либо в decimal-формате;
  • API передаёт код валюты отдельно;
  • интерфейс форматирует данные согласно активной локали.

Пример данных:

{
  "price": 1999.5,
  "currency": "USD"
}

Globalize не занимается конвертацией валют. Библиотека отвечает именно за локализованное представление.


Подключение CLDR для валют

Для корректной работы необходимо подключить данные CLDR.

Установка:

npm install globalize cldr-data

Подключение:

const Globalize = require("globalize");

Globalize.load(
  require("cldr-data/main/en/numbers.json"),
  require("cldr-data/main/en/currencies.json"),
  require("cldr-data/main/ru/numbers.json"),
  require("cldr-data/main/ru/currencies.json"),
  require("cldr-data/supplemental/likelySubtags.json"),
  require("cldr-data/supplemental/numberingSystems.json"),
  require("cldr-data/supplemental/plurals.json")
);

После загрузки можно переключать локали:

Globalize.locale("en");

Форматирование валют

Главный инструмент — currencyFormatter.

Базовый пример:

const formatter = Globalize.currencyFormatter("USD");

console.log(formatter(1999.5));

Результат для английской локали:

$1,999.50

Для русской:

Globalize.locale("ru");

const formatter = Globalize.currencyFormatter("USD");

console.log(formatter(1999.5));

Результат:

1 999,50 $

Разница включает:

  • положение символа валюты;
  • разделитель дробной части;
  • разделитель тысяч;
  • формат пробелов.

Использование разных валют одновременно

В реальном приложении одна страница часто отображает несколько валют.

Пример:

const prices = [
  { amount: 1200, currency: "USD" },
  { amount: 950, currency: "EUR" },
  { amount: 560000, currency: "KZT" }
];

Globalize.locale("ru");

prices.forEach(item => {
  const formatter = Globalize.currencyFormatter(item.currency);

  console.log(formatter(item.amount));
});

Результат:

1 200,00 $
950,00 €
560 000,00 ₸

Кэширование форматтеров

Создание форматтера — относительно дорогая операция. При большом количестве валют форматтеры необходимо кэшировать.

Плохой вариант:

items.forEach(item => {
  const formatter = Globalize.currencyFormatter(item.currency);

  render(formatter(item.price));
});

Оптимизированный подход:

const formatterCache = {};

function getFormatter(currency) {
  if (!formatterCache[currency]) {
    formatterCache[currency] =
      Globalize.currencyFormatter(currency);
  }

  return formatterCache[currency];
}

Использование:

items.forEach(item => {
  const formatter = getFormatter(item.currency);

  render(formatter(item.price));
});

Такой подход особенно важен:

  • в React;
  • в Vue;
  • в Angular;
  • при SSR;
  • в таблицах с тысячами строк.

Работа с пользовательской локалью

Валюты и локали — разные сущности.

Например:

Локаль Валюта
ru USD
en EUR
de KZT

Пользователь может находиться в Казахстане, но работать с долларами.

Корректная архитектура:

Globalize.locale(userLocale);

const formatter =
  Globalize.currencyFormatter(currencyCode);

Нельзя жёстко связывать:

ru -> RUB
en -> USD

Такая модель быстро ломается в международных приложениях.


Отображение символов валют

CLDR автоматически использует корректные обозначения валют.

Примеры:

Globalize.locale("en");

console.log(
  Globalize.currencyFormatter("USD")(50)
);

console.log(
  Globalize.currencyFormatter("EUR")(50)
);

console.log(
  Globalize.currencyFormatter("JPY")(50)
);

Результат:

$50.00
€50.00
¥50

Обратить внимание необходимо на:

  • отсутствие дробной части у иены;
  • локальные символы;
  • правила округления.

Валюты без дробной части

Некоторые валюты не используют десятичные значения.

Например:

  • JPY;
  • KRW;
  • VND.

Globalize автоматически применяет правила CLDR.

Globalize.locale("ja");

const formatter =
  Globalize.currencyFormatter("JPY");

console.log(formatter(1200.75));

Результат:

¥1,201

Происходит автоматическое округление.


Настройка минимального и максимального количества знаков

Иногда требуется переопределить стандартные правила.

Пример:

const formatter = Globalize.numberFormatter({
  style: "currency",
  currency: "USD",
  minimumFractionDigits: 4,
  maximumFractionDigits: 4
});

console.log(formatter(12.5));

Результат:

$12.5000

Полезно:

  • для криптовалют;
  • биржевых интерфейсов;
  • финансовых панелей;
  • B2B-систем.

Форматирование криптовалют

CLDR не содержит встроенных правил для криптовалют.

Для Bitcoin:

const formatter = Globalize.numberFormatter({
  minimumFractionDigits: 8,
  maximumFractionDigits: 8
});

console.log(formatter(0.12345678));

Результат:

0.12345678

Символ добавляется вручную:

const value = formatter(0.12345678);

console.log(`₿ ${value}`);

Динамическое переключение локалей

Современные приложения позволяют менять язык интерфейса без перезагрузки.

Пример:

function formatPrice(amount, currency, locale) {
  Globalize.locale(locale);

  const formatter =
    Globalize.currencyFormatter(currency);

  return formatter(amount);
}

Использование:

console.log(
  formatPrice(1000, "USD", "en")
);

console.log(
  formatPrice(1000, "USD", "fr")
);

console.log(
  formatPrice(1000, "USD", "ru")
);

Результаты:

$1,000.00
1 000,00 $US
1 000,00 $

Проблемы глобального состояния locale

Метод:

Globalize.locale(...)

изменяет глобальное состояние.

В больших приложениях это создаёт проблемы:

  • race condition;
  • конфликт SSR-запросов;
  • ошибки асинхронного рендеринга;
  • нестабильность тестов.

Безопаснее использовать экземпляры:

const ru = new Globalize("ru");
const en = new Globalize("en");

Далее:

const ruFormatter =
  ru.currencyFormatter("USD");

const enFormatter =
  en.currencyFormatter("USD");

SSR и множественные валюты

При серверном рендеринге особенно важно избегать глобального состояния.

Плохой вариант:

Globalize.locale(user.locale);

При одновременных запросах локали будут конфликтовать.

Правильный подход:

function createFormatter(locale, currency) {
  const instance = new Globalize(locale);

  return instance.currencyFormatter(currency);
}

Использование в React

Пример хука:

import Globalize from "globalize";

export function useCurrency(locale, currency) {
  const globalize = new Globalize(locale);

  return globalize.currencyFormatter(currency);
}

Компонент:

function Price({ value, locale, currency }) {
  const format = useCurrency(locale, currency);

  return <span>{format(value)}</span>;
}

Использование в Vue

Пример composable:

import Globalize from "globalize";

export function useCurrency(locale) {
  function format(amount, currency) {
    const globalize = new Globalize(locale);

    return globalize
      .currencyFormatter(currency)(amount);
  }

  return { format };
}

Таблицы с несколькими валютами

Типичная проблема — производительность.

Например:

Товар Валюта
Laptop USD
Phone EUR
Camera GBP

Если таблица содержит тысячи строк:

  • нельзя создавать форматтер на каждую ячейку;
  • нельзя пересоздавать Globalize-инстансы;
  • нельзя менять locale глобально.

Эффективная архитектура:

const instances = {};
const formatters = {};

function getGlobalize(locale) {
  if (!instances[locale]) {
    instances[locale] = new Globalize(locale);
  }

  return instances[locale];
}

function getFormatter(locale, currency) {
  const key = `${locale}-${currency}`;

  if (!formatters[key]) {
    formatters[key] =
      getGlobalize(locale)
        .currencyFormatter(currency);
  }

  return formatters[key];
}

Конвертация валют

Globalize не выполняет обменные операции.

Неверный подход:

Globalize.convertCurrency(...)

Такого API не существует.

Конвертация должна происходить отдельно:

function convert(amount, rate) {
  return amount * rate;
}

После конвертации используется форматирование:

const converted = convert(100, 0.92);

const formatter =
  Globalize.currencyFormatter("EUR");

console.log(formatter(converted));

Хранение денежных значений

Плавающая точка вызывает ошибки:

0.1 + 0.2

Результат:

0.30000000000000004

Для финансовых систем используют:

  • integer minor units;
  • decimal libraries;
  • BigInt;
  • decimal.js;
  • big.js.

Пример хранения в центах:

{
  amount: 199950,
  currency: "USD"
}

Форматирование:

const dollars = amount / 100;

Локализация названий валют

CLDR содержит локализованные названия.

Пример:

const globalize = new Globalize("ru");

console.log(
  globalize.cldr.main([
    "numbers",
    "currencies",
    "USD",
    "displayName"
  ])
);

Результат:

доллар США

Для английской локали:

US Dollar

Компактное отображение сумм

Иногда интерфейс требует сокращённых форматов.

Пример:

const formatter = Globalize.numberFormatter({
  minimumFractionDigits: 1,
  maximumFractionDigits: 1
});

console.log(formatter(1200000));

Для финансовых панелей дополнительно реализуют:

1.2M USD

или:

₸ 2.5 млн

Globalize предоставляет основу локализации, а бизнес-логика сокращений реализуется отдельно.


Поддержка RTL-локалей

Арабские локали имеют особенности отображения:

Globalize.locale("ar");

const formatter =
  Globalize.currencyFormatter("USD");

console.log(formatter(1500));

Формат включает:

  • RTL-направление;
  • арабские цифры;
  • локальные разделители.

Такие интерфейсы требуют дополнительного тестирования CSS.


Ошибки при работе с валютами

Смешивание локали и валюты

Ошибка:

if (locale === "ru") {
  currency = "RUB";
}

Локаль не определяет валюту.


Форматирование до конвертации

Неверно:

"$100" * 0.9

Сначала вычисления, затем форматирование.


Хранение форматированных строк

Ошибка:

{
  total: "$1,200.00"
}

Необходимо хранить исходные значения:

{
  total: 1200,
  currency: "USD"
}

Использование Number для больших сумм

Для финансовых систем с высокой точностью Number недостаточен.


Масштабирование мультиязычной валютной системы

Крупные приложения обычно разделяют систему на уровни:

Уровень данных

{
  amountMinor: 150099,
  currency: "USD"
}

Уровень конвертации

convertCurrency(amount, from, to)

Уровень локализации

formatCurrency(amount, currency, locale)

UI-уровень

<Price />

Такое разделение:

  • упрощает тестирование;
  • снижает количество ошибок;
  • облегчает миграции;
  • позволяет менять библиотеку локализации без переписывания бизнес-логики.