Уявіть: відвідувач з Києва заходить на ваш інтернет-магазин, бачить ціни в доларах і закриває вкладку. Втрата конверсії — до 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, ); } // Автоконвертація з базової ціни (USD) $basePrice = $product->price; $rate = $this->getRate('USD', $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) { 'USD' => $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' => 'USD', '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 успішних впроваджень. Гарантуємо результат. Замовте розробку системи мультивалютності вже сьогодні.







