Реализация мультивалютности на сайте
Мультивалютность — это не просто умножение цены на курс. Это система, которая определяет, в какой валюте показывать цены, как хранить их в базе, как обрабатывать платежи в нескольких валютах и как сводить финансовую отчётность воедино. Плохо реализованная мультивалютность порождает расхождения в бухгалтерии, баги с округлением и проблемы с НДС.
Стратегии хранения цен
Есть два принципиально разных подхода:
Стратегия 1: Хранение в базовой валюте, конвертация на лету
Все цены хранятся в одной валюте (USD, EUR или локальная), при отображении умножаются на актуальный курс. Просто в реализации, но курс меняется — покупатель видит разные цены при каждом визите. Подходит для B2B и информационных сайтов.
Стратегия 2: Явные цены в каждой валюте
В базе хранится цена отдельно для каждой валюты. Менеджер управляет ценами вручную или с помощью автообновления по курсу. Покупатель видит фиксированную «красивую» цену (999 руб. а не 997.34 руб.). Подходит для розницы.
Для e-commerce почти всегда выбирается второй подход с полуавтоматическим обновлением:
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY, -- ISO 4217: RUB, USD, EUR, BYN
name VARCHAR(100) NOT NULL,
symbol VARCHAR(10) NOT NULL,
symbol_pos VARCHAR(10) NOT NULL DEFAULT 'after', -- before | after
decimals SMALLINT NOT NULL DEFAULT 2,
is_active BOOLEAN NOT NULL DEFAULT true,
is_default BOOLEAN NOT NULL DEFAULT false,
rate_to_base NUMERIC(15,6) NOT NULL DEFAULT 1.0 -- к базовой валюте
);
CREATE TABLE product_prices (
id BIGSERIAL PRIMARY KEY,
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
currency CHAR(3) NOT NULL REFERENCES currencies(code),
price NUMERIC(12,2) NOT NULL,
compare_at NUMERIC(12,2), -- зачёркнутая цена
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (variant_id, currency)
);
Автообновление курсов
Курсы обновляются по расписанию из публичных источников:
class ExchangeRateUpdater
{
private array $providers = [
CbrExchangeRateProvider::class, // ЦБ РФ
NbrbExchangeRateProvider::class, // НБРБ (Беларусь)
EcbExchangeRateProvider::class, // ЕЦБ
];
public function update(): void
{
foreach ($this->providers as $providerClass) {
$provider = app($providerClass);
$rates = $provider->fetchRates();
foreach ($rates as $code => $rate) {
Currency::where('code', $code)->update([
'rate_to_base' => $rate,
]);
}
}
Cache::tags(['currencies'])->flush();
}
}
ЦБ РФ публикует XML по адресу https://www.cbr.ru/scripts/XML_daily.asp. НБРБ — JSON API: https://api.nbrb.by/exrates/rates?periodicity=0.
Автообновление курсов не означает автопересчёт цен в product_prices. Это отдельный шаг — либо ручной (менеджер нажимает «Пересчитать по курсу»), либо автоматический с порогом отклонения (пересчитывать только если курс изменился более чем на 2%).
Выбор валюты пользователем
Пользователь выбирает валюту в шапке сайта. Выбор сохраняется:
- Для гостей — в cookie
preferred_currency(срок 90 дней) - Для авторизованных — в
users.preferred_currency
Middleware определяет текущую валюту при каждом запросе:
class ResolveCurrency
{
public function handle(Request $request, Closure $next): Response
{
$currency = $this->detectCurrency($request);
app()->instance('current_currency', Currency::find($currency));
$request->merge(['currency' => $currency]);
return $next($request);
}
private function detectCurrency(Request $request): string
{
// 1. Явный параметр в запросе (переключатель в шапке)
if ($request->has('currency') && $this->isValid($request->currency)) {
$this->persistChoice($request, $request->currency);
return $request->currency;
}
// 2. Сохранённый выбор пользователя
if ($request->user()?->preferred_currency) {
return $request->user()->preferred_currency;
}
// 3. Cookie
if ($cookie = $request->cookie('preferred_currency')) {
return $cookie;
}
// 4. GeoIP (если включено)
return $this->geoipCurrency->detect($request->ip())
?? config('shop.default_currency', 'RUB');
}
}
Форматирование цен
Форматирование — не тривиальная задача: разные валюты имеют разные разделители и позиции символа:
class PriceFormatter
{
public function format(float $amount, Currency $currency): string
{
$formatted = number_format(
$amount,
$currency->decimals,
',', // десятичный разделитель
' ' // разделитель тысяч
);
return match($currency->symbol_pos) {
'before' => $currency->symbol . $formatted,
'after' => $formatted . ' ' . $currency->symbol,
};
}
}
// 1 499,00 ₽
// $1,499.00
// 1.499,00 €
Платежи в нескольких валютах
Платёжный шлюз должен поддерживать мультивалютность. Stripe — идеально: принимает платёж в любой валюте, конвертирует на стороне процессора. ЮKassa — только в рублях, конвертация на стороне магазина. CloudPayments — BYN, RUB, USD, EUR.
При оплате фиксируется валюта заказа и курс на момент оплаты:
ALTER TABLE orders ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'RUB';
ALTER TABLE orders ADD COLUMN exchange_rate NUMERIC(15,6) NOT NULL DEFAULT 1.0;
ALTER TABLE orders ADD COLUMN base_currency_total NUMERIC(12,2); -- для отчётности
Это позволяет потом свести отчётность в единой валюте вне зависимости от того, в чём платил покупатель.
Округление и anti-patterns
Никогда не хранить деньги в FLOAT — потеря точности при математике. Всегда NUMERIC(12,2) или DECIMAL.
Округление при конвертации:
// НЕПРАВИЛЬНО: 9.994999... → 9.99 но 9.995000 → 10.00 (PHP_ROUND_HALF_UP)
round($price * $rate, 2);
// ПРАВИЛЬНО: банковское округление, ошибка не накапливается
round($price * $rate, 2, PHP_ROUND_HALF_EVEN);
При суммировании позиций заказа — сначала суммируем, потом округляем, не наоборот.
Отображение в каталоге
При индексации каталога через Elasticsearch или Meilisearch — индексировать цены во всех активных валютах как отдельные поля для фильтрации:
{
"id": 123,
"price_rub": 1499.00,
"price_usd": 16.50,
"price_eur": 15.20,
"price_byn": 52.10
}
Это позволяет фильтровать по цене в текущей валюте пользователя без пересчёта при каждом запросе.
Сроки реализации
- Базовая система: хранение цен + переключатель валюты + форматирование: 3–4 дня
- Автообновление курсов (ЦБ РФ / НБРБ): 1 день
- Автопересчёт цен с порогом отклонения: 1–2 дня
- Мультивалютные платежи (зависит от шлюза): 2–4 дня
- Финансовая отчётность в базовой валюте: 1–2 дня
Полная реализация для магазина с 3–5 валютами: 1–2 недели.







