Implementing Regional Prices and Currencies on a Website
Regional pricing is essential if your business operates across multiple countries or applies different pricing policies for different regions within a country. It's not just currency conversion — it's separate price lists, local promotions, market-based rounding rules, and correct currency symbol display.
Use Cases
- Multi-currency store — prices in RUB, USD, EUR, with automatic conversion or manual price lists
- Regional prices within a country — Moscow and other regions may have different prices
- Contract prices — dealer and B2B prices for specific customers or groups
Architecture
Request → RegionDetector (IP/Cookie/URL) → PriceResolver → Formatting
Data Schema
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
);
-- Regional prices (if not specified — converted from base price)
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)
);
User Region Detection
class RegionDetector
{
public function detect(Request $request): PriceRegion
{
// 1. User's explicit choice (session / cookie)
if ($request->session()->has('price_region')) {
$region = PriceRegion::find($request->session()->get('price_region'));
if ($region) return $region;
}
// 2. URL parameter or subdomain (/en/, by.example.com)
if ($regionCode = $this->detectFromUrl($request)) {
$region = PriceRegion::whereJsonContains('country_codes', $regionCode)->first();
if ($region) return $region;
}
// 3. IP geolocation
$countryCode = $this->geoip->getCountry($request->ip());
if ($countryCode) {
$region = PriceRegion::whereJsonContains('country_codes', $countryCode)->first();
if ($region) return $region;
}
// 4. Default region
return PriceRegion::where('is_default', true)->firstOrFail();
}
}
IP geolocation via 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();
}
}
}
Pricing Service
class RegionalPriceService
{
public function getPrice(Product $product, PriceRegion $region): RegionalPrice
{
// 1. Check if manual price exists for the region
$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. Auto-convert from base price
$basePrice = $product->price; // in base currency (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
{
// Psychological rounding for each currency
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, // multiples of 0.50
default => round($amount, $currency->decimals),
};
}
private function roundTo99(float $amount, float $step): float
{
$rounded = ceil($amount / $step) * $step;
// Replace last digits with 9: 1301 → 1299
if ($step >= 1) {
$magnitude = 10 ** (strlen((int)$rounded) - 2);
return floor($rounded / $magnitude) * $magnitude + ($magnitude - 1);
}
return $rounded;
}
}
Automatic Exchange Rate Updates
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 exchange rate updates
$schedule->job(new FetchExchangeRatesJob)->dailyAt('10:00');
Middleware and Context
class SetPriceRegionMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$region = $this->detector->detect($request);
// Pass to application context
app()->instance('price_region', $region);
// For Inertia.js — pass to frontend
Inertia::share('priceRegion', [
'id' => $region->id,
'currency' => [
'code' => $region->currency->code,
'symbol' => $region->currency->symbol,
'pos' => $region->currency->symbol_pos,
],
]);
return $next($request);
}
}
Frontend Price Formatting
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}`;
}
Region Switcher Widget
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>
);
};
Implementation Timeline
- Data schema + RegionDetector + PriceService: 1–2 days
- Automatic exchange rate updates (Central Bank): 0.5 days
- Middleware + Inertia sharing: 0.5 days
- Frontend formatting + region switcher: 1 day
- Manual prices in admin interface: 1 day
Total: 4–5 working days.







