Реалізація регіональних цін та валют на веб-сайті
Регіональне ціноутворення необхідне, якщо ваш бізнес працює в кількох країнах або застосовує різні цінові політики для різних регіонів в межах країни. Це не просто конвертація за курсом — це окремі прайс-листи, локальні акції, округлення за правилами ринку та коректне відображення значка валюти.
Сценарії використання
- Багатовалютний магазин — ціни в 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 робочих днів.







