Реализация региональных цен и валют на сайте
Региональное ценообразование необходимо, если бизнес работает в нескольких странах или использует разные ценовые политики для разных регионов внутри страны. Это не просто конвертация по курсу — это отдельные прайс-листы, локальные акции, округление по правилам рынка и корректное отображение валютного знака.
Сценарии использования
- Мультивалютный магазин — цены в RUB, USD, EUR, с автоматическим пересчётом или ручными прайсами
- Региональные цены внутри страны — Москва и регионы могут иметь разные цены
- Контрактные цены — дилерские, B2B цены для отдельных клиентов или групп
Архитектура
Запрос → RegionDetector (IP/Cookie/URL) → PriceResolver → Форматирование
Схема данных
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY, -- 'RUB', 'USD', 'EUR', 'BYN'
symbol VARCHAR(5) NOT NULL, -- '₽', '$', '€', 'Br'
symbol_pos VARCHAR(10) DEFAULT 'after', -- 'before'|'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), -- 'cbr', 'manual', 'ecb'
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)[], -- ['RU'], ['BY'], ['KZ']
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
{
// 1. Явный выбор пользователя (сессия / cookie)
if ($request->session()->has('price_region')) {
$region = PriceRegion::find($request->session()->get('price_region'));
if ($region) return $region;
}
// 2. URL-параметр или поддомен (/en/, by.example.com)
if ($regionCode = $this->detectFromUrl($request)) {
$region = PriceRegion::whereJsonContains('country_codes', $regionCode)->first();
if ($region) return $region;
}
// 3. IP-геолокация
$countryCode = $this->geoip->getCountry($request->ip());
if ($countryCode) {
$region = PriceRegion::whereJsonContains('country_codes', $countryCode)->first();
if ($region) return $region;
}
// 4. Дефолтный регион
return PriceRegion::where('is_default', true)->firstOrFail();
}
}
IP-геолокация через MaxMind GeoLite2:
use MaxMind\Db\Reader;
class MaxMindGeoIp
{
public function getCountry(string $ip): ?string
{
$reader = new Reader(storage_path('app/GeoLite2-Country.mmdb'));
try {
$record = $reader->country($ip);
return $record->country->isoCode;
} catch (\Exception $e) {
return null;
} finally {
$reader->close();
}
}
}
Сервис ценообразования
class RegionalPriceService
{
public function getPrice(Product $product, PriceRegion $region): RegionalPrice
{
// 1. Есть ли ручной прайс для региона
$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,
);
}
// 2. Автоконвертация из базовой цены
$basePrice = $product->price; // в базовой валюте (RUB)
$rate = $this->getRate('RUB', $region->currency->code);
$converted = $this->roundByCurrency(
amount: $basePrice * $rate,
currency: $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), // 1299, 4999, 29990
'USD' => $this->roundTo99($amount, 0.01), // 29.99, 149.95
'EUR' => $this->roundTo99($amount, 0.01),
'BYN' => round($amount * 2) / 2, // кратно 0.50
default => round($amount, $currency->decimals),
};
}
private function roundTo99(float $amount, float $step): float
{
$rounded = ceil($amount / $step) * $step;
// Заменить последние цифры на 9: 1301 → 1299
if ($step >= 1) {
$magnitude = 10 ** (strlen((int)$rounded) - 2);
return floor($rounded / $magnitude) * $magnitude + ($magnitude - 1);
}
return $rounded;
}
}
Автообновление курсов
class ExchangeRateFetcher
{
public function fetchFromCbr(): void
{
$response = Http::get('https://www.cbr.ru/scripts/XML_daily.asp');
$xml = simplexml_load_string($response->body());
$rates = [];
foreach ($xml->Valute as $valute) {
$code = (string) $valute->CharCode;
$nominal = (float) $valute->Nominal;
$value = (float) str_replace(',', '.', (string) $valute->Value);
if (in_array($code, ['USD', 'EUR', 'BYN', 'KZT'])) {
ExchangeRate::updateOrCreate(
['from_currency' => 'RUB', 'to_currency' => $code],
[
'rate' => $nominal / $value,
'source' => 'cbr',
'fetched_at' => now(),
]
);
}
}
}
}
// Обновление курсов в расписании
$schedule->job(new FetchExchangeRatesJob)->dailyAt('10:00');
Middleware и контекст
class SetPriceRegionMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$region = $this->detector->detect($request);
// Передаём в контекст приложения
app()->instance('price_region', $region);
// Для Inertia.js — передаём на фронтенд
Inertia::share('priceRegion', [
'id' => $region->id,
'currency' => [
'code' => $region->currency->code,
'symbol' => $region->currency->symbol,
'pos' => $region->currency->symbol_pos,
],
]);
return $next($request);
}
}
Форматирование цены на фронтенде
interface Currency {
code: string;
symbol: string;
pos: 'before' | 'after';
decimals: number;
}
function formatPrice(amount: number, currency: Currency): string {
const formatted = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: currency.decimals,
maximumFractionDigits: currency.decimals,
}).format(amount);
return currency.pos === 'before'
? `${currency.symbol}${formatted}`
: `${formatted} ${currency.symbol}`;
}
Виджет смены региона
const RegionSwitcher: React.FC = () => {
const { priceRegion } = usePage<{ priceRegion: PriceRegion }>().props;
const { data: regions } = useQuery(['regions'], fetchRegions);
return (
<select
value={priceRegion.id}
onChange={e => {
router.post('/region/switch', { region_id: e.target.value });
}}
className="text-sm border rounded px-2 py-1"
>
{regions?.map(r => (
<option key={r.id} value={r.id}>{r.currency.code} — {r.name}</option>
))}
</select>
);
};
Сроки реализации
- Схема данных + RegionDetector + PriceService: 1–2 дня
- Автообновление курсов (ЦБ РФ): 0.5 дня
- Middleware + Inertia-шеринг: 0.5 дня
- Фронтенд форматирование + виджет смены региона: 1 день
- Ручные прайсы в интерфейсе админки: 1 день
Итого: 4–5 рабочих дней.







