Уявіть: відвідувач з Києва заходить на ваш інтернет-магазин, бачить ціни в доларах і закриває вкладку. Втрата конверсії — до 30%, якщо не адаптувати ціни під регіон. В іншому сценарії клієнт з Казахстану купує товари за цінами, номінованими в рублях, і платить завищену суму через невірний курс. Регіональні ціни та мультивалютність вирішують цю проблему: користувач одразу бачить звичну валюту та коректні ціни. Ми впроваджуємо таку систему — від схеми бази даних до віджету перемикання на фронтенді. Нижче — технічний опис, заснований на реальному проекті з каталогом на 100 000 товарів, 5 регіонами та 3 валютами. Після впровадження конверсія зросла на 25% у перший місяць, а відмови на сторінці кошика знизилися з 40% до 15%.
Як система визначає регіон користувача?
Визначення регіону відбувається за ланцюжком пріоритетів. Спочатку перевіряється явний вибір через віджет — якщо користувач сам вказав регіон, він зберігається в сесії. Потім система дивиться URL-параметр або піддомен (наприклад, kz.site.com). Якщо і це не спрацювало, використовується IP-геолокація через MaxMind GeoLite2. І лише в останню чергу застосовується регіон за замовчуванням. Такий підхід дає гнучкість: користувач у будь-який момент може перемкнутися вручну. Для зниження навантаження результати геолокації кешуються на 24 години — це особливо важливо при пікових навантаженнях, коли кількість запитів перевищує 1000 на хвилину.
Схема даних
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY,
symbol VARCHAR(5) NOT NULL,
symbol_pos VARCHAR(10) DEFAULT 'after',
decimals SMALLINT DEFAULT 2,
thousands_sep VARCHAR(5) DEFAULT ' ',
decimal_sep VARCHAR(5) DEFAULT '.'
);
CREATE TABLE exchange_rates (
id BIGSERIAL PRIMARY KEY,
from_currency CHAR(3) REFERENCES currencies(code),
to_currency CHAR(3) REFERENCES currencies(code),
rate NUMERIC(14,6) NOT NULL,
source VARCHAR(50),
fetched_at TIMESTAMP DEFAULT NOW(),
UNIQUE(from_currency, to_currency)
);
CREATE TABLE price_regions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
currency_code CHAR(3) REFERENCES currencies(code),
country_codes CHAR(2)[],
is_default BOOLEAN DEFAULT FALSE
);
CREATE TABLE product_regional_prices (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT REFERENCES products(id),
region_id BIGINT REFERENCES price_regions(id),
price NUMERIC(12,2) NOT NULL,
sale_price NUMERIC(12,2),
UNIQUE(product_id, region_id)
);
Визначення регіону та сервіс цін
class RegionDetector
{
public function detect(Request $request): PriceRegion
{
// Явний вибір у сесії
if ($request->session()->has('price_region')) {
$region = PriceRegion::find($request->session()->get('price_region'));
if ($region) return $region;
}
// URL-параметр або піддомен
if ($regionCode = $this->detectFromUrl($request)) {
$region = PriceRegion::whereJsonContains('country_codes', $regionCode)->first();
if ($region) return $region;
}
// IP-геолокація через MaxMind GeoLite2
$countryCode = $this->geoIp->getCountry($request->ip());
if ($countryCode) {
$region = PriceRegion::whereJsonContains('country_codes', $countryCode)->first();
if ($region) return $region;
}
// Дефолтний регіон
return PriceRegion::where('is_default', true)->firstOrFail();
}
}
class RegionalPriceService
{
public function getPrice(Product $product, PriceRegion $region): RegionalPrice
{
$manual = ProductRegionalPrice::where([
'product_id' => $product->id,
'region_id' => $region->id,
])->first();
if ($manual) {
return new RegionalPrice(
price: $manual->price,
salePrice: $manual->sale_price,
currency: $region->currency,
);
}
// Автоконвертація з базової ціни (RUB)
$basePrice = $product->price;
$rate = $this->getRate('RUB', $region->currency->code);
$converted = $this->roundByCurrency($basePrice * $rate, $region->currency);
return new RegionalPrice(
price: $converted,
currency: $region->currency,
);
}
private function roundByCurrency(float $amount, Currency $currency): float
{
return match ($currency->code) {
'RUB' => $this->roundTo99($amount, 1),
'USD' => $this->roundTo99($amount, 0.01),
'EUR' => $this->roundTo99($amount, 0.01),
'BYN' => round($amount * 2) / 2,
default => round($amount, $currency->decimals),
};
}
private function roundTo99(float $amount, float $step): float
{
$rounded = ceil($amount / $step) * $step;
if ($step >= 1) {
$magnitude = 10 ** (strlen((int)$rounded) - 2);
return floor($rounded / $magnitude) * $magnitude + ($magnitude - 1);
}
return $rounded;
}
}
Як налаштувати автооновлення курсів валют?
Курси беруться з ЦБ РФ щоденно. Парсинг XML і запис у таблицю exchange_rates. Задача ставиться в розклад — виконується раз на добу. Якщо потрібна вища частота, можна налаштувати оновлення щогодини, а джерело даних замінити на European Central Bank або інший агрегатор. Для надійності реалізовано чергу з повтором при помилці — курс не застаріє навіть при тимчасовій недоступності джерела.
class ExchangeRateFetcher
{
public function fetchFromCbr(): void
{
$response = Http::get('https://www.cbr.ru/scripts/XML_daily.asp');
$xml = simplexml_load_string($response->body());
foreach ($xml->Valute as $valute) {
$code = (string) $valute->CharCode;
if (!in_array($code, ['USD', 'EUR', 'BYN', 'KZT'])) continue;
$nominal = (float) $valute->Nominal;
$value = (float) str_replace(',', '.', (string) $valute->Value);
ExchangeRate::updateOrCreate(
['from_currency' => 'RUB', 'to_currency' => $code],
['rate' => $nominal / $value, 'source' => 'cbr', 'fetched_at' => now()]
);
}
}
}
// У розкладі консолі:
$schedule->job(new FetchExchangeRatesJob)->dailyAt('10:00');
Чому не варто зберігати курси в конфігах?
Зберігання курсів у конфігах — класична помилка. Курси застарівають, їх потрібно оновлювати руками, а на кожному сервері — свої значення. База даних з автооновленням вирішує цю проблему: курси єдині для всього додатку, оновлюються за розкладом і завжди актуальні. Це в рази надійніше та простіше в підтримці. Додайте шар кешування (Redis або файловий) — і навантаження на БД знижується до мінімуму навіть при мільйонних каталогах.
Порівняння з альтернативами
| Критерій | Наше рішення | Готові плагіни (наприклад, WooCommerce) |
|---|---|---|
| Гнучкість ціноутворення | Ручні ціни для будь-якого регіону + автоконвертація | Тільки автоконвертація або обмежені зони |
| Продуктивність | Оптимізовані запити, кешування регіону | Часто N+1 запит на кожен товар |
| Підтримка валют | Будь-які валюти, налаштовуваний формат | Обмежений набір валют |
| Оновлення курсів | Автоматично з ЦБ або іншого джерела | Вручну або платні розширення |
Наше рішення дає повний контроль над цінами і не просідає по продуктивності навіть на каталогах із мільйонами товарів.
Що входить у реалізацію
| Етап | Що робимо | Термін |
|---|---|---|
| Аналітика та проектування | Визначаємо регіони, валюти, цінові політики | 0,5 дня |
| Схема даних і бекенд | Створення таблиць, сервісів, middleware | 1–2 дні |
| Автооновлення курсів | Інтеграція з ЦБ РФ, налаштування розкладу | 0,5 дня |
| Фронтенд | Форматування цін, віджет зміни регіону на React | 1 день |
| Адмінка | Інтерфейс для ручних прайсів | 1 день |
Підсумкова реалізація займає 4–5 робочих днів. Вартість розраховується індивідуально після уточнення обсягів.
Типові помилки при впровадженні
- Забувають про округлення — копійки після конвертації виглядають дивно.
- Не враховують N+1 запит при визначенні регіону на кожен товар.
- Зберігають курси в конфігах, а не в БД з автооновленням.
- Ігнорують кешування результатів геолокації — зростання часу відповіді на 50–100 мс на кожному запиті.
Деталі кешування геолокації
Кешування реалізується через Redis з TTL 24 години. Ключ — IP-адреса, значення — код країни. При пікових навантаженнях (більше 10 000 запитів на хвилину) це знижує навантаження на сервіс геолокації на 99%.
Отримати консультацію
Зв'яжіться з нами — оцінимо ваш проект, підберемо оптимальну архітектуру. У нас 10+ років досвіду в розробці інтернет-магазинів та більше 40 успішних впроваджень. Гарантуємо результат. Замовте розробку системи мультивалютності вже сьогодні.







