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







