Реалізація мультивалютності на сайті
Мультивалютність — це не просто умноження ціни на курс. Це система, яка визначає, в якій валюті показувати ціни, як їх зберігати в базі, як обробляти платежі в кількох валютах та як сводити фінансову звітність воєдино. Погано реалізована мультивалютність породжує розбіжності в бухгалтерії, баги з округленням та проблеми з НДС.
Стратегії зберігання цін
Є два принципово різних підходи:
Стратегія 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 тижні.







