Представьте: посетитель из Минска заходит на ваш интернет-магазин, видит цены в долларах и закрывает вкладку. Потеря конверсии — до 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+ лет опыта в разработке интернет-магазинов и более 50 успешных внедрений. Гарантируем результат. Закажите разработку системы мультивалютности уже сегодня.







