Price rules per store without rounding errors
Magento 2 currency conversion looks like a simple factor between two numbers at first glance, but in practice it is an interplay of base currency, rate import, rounding rules and price rules that must be configured differently per store. Anyone who confuses base currency and display currency risks price drift with every rate update and discount rules that apply in the wrong currency.
Table of Contents
- 1. Why currency conversion is more than a factor
- 2. Base currency versus display currency
- 3. Price scope: website scope decides on rates
- 4. Rate import: cron, providers and custom connectors
- 5. Rounding and price display without decimal chaos
- 6. Correctly limiting cart price rules per store view
- 7. Tax classes and currency working together
- 8. Common pitfalls with rate changes
- 9. Strategies compared: fixed rate versus live rate
- 10. Summary
- 11. FAQ
1. Why currency conversion is more than a factor
As soon as a Magento 2 shop serves multiple countries with different national currencies, Magento 2 currency conversion becomes one of those topics that looks trivial at first glance and turns out to have surprisingly many moving parts in practice. It is not enough to simply store a conversion factor. Magento strictly distinguishes between the base currency, in which prices are stored internally and orders are processed for accounting, and the display currency, which is presented to the customer in the respective store view.
This separation is not an implementation detail but a deliberate architectural decision: it allows a store to offer multiple display currencies without having to maintain the underlying pricing logic and discount rules multiple times. At the same time, this creates sources of error that are hard to diagnose without understanding the underlying mechanics, for example when a price rule per store unexpectedly discounts a different amount than expected because it operates on base currency instead of display currency.
2. Base currency versus display currency
Magento stores every product price, every invoice and every order line item primarily in the base currency of the respective website, configured under currency/options/base. This base currency is deliberately scoped at website level rather than store view level, because a website as an economic unit has exactly one accounting currency. The display currency, on the other hand, configured under currency/options/default and currency/options/allow, is valid at store view level and determines in which currency prices are presented to the customer.
For price rules per store this means: a cart price rule defining a fixed discount amount, say ten euros off, technically refers to the website's base currency and is only converted to the respective store view currency for display. Anyone running a website with base currency euro but offering a store view with display currency US dollar has to account for this conversion step for every fixed discount amount, since the actual dollar value of the discount changes with every rate update.
<?php
declare(strict_types=1);
namespace Mironsoft\CurrencyTools\ViewModel;
use Magento\Directory\Model\CurrencyFactory;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;
/**
* ViewModel exposing the effective currency conversion rate for a store view.
*/
final class ConversionRate implements ArgumentInterface
{
/**
* @param StoreManagerInterface $storeManager Resolves store base currency
* @param CurrencyFactory $currencyFactory Creates currency conversion models
*/
public function __construct(
private readonly StoreManagerInterface $storeManager,
private readonly CurrencyFactory $currencyFactory
) {
}
/**
* Get the current conversion rate from the website base currency
* to the store view display currency.
*
* @return float Conversion rate, 1.0 if currencies are identical
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function getRate(): float
{
$store = $this->storeManager->getStore();
$baseCode = $store->getBaseCurrencyCode();
$displayCode = $store->getCurrentCurrencyCode();
if ($baseCode === $displayCode) {
return 1.0;
}
$currency = $this->currencyFactory->create()->load($baseCode);
return (float) $currency->getRate($displayCode);
}
}
3. Price scope: website scope decides on rates
The configuration value catalog/price/scope controls whether product prices are maintained globally or individually per website, and this decision directly affects Magento 2 currency conversion. With global price scope, only one base price exists per product, which is automatically converted using the stored rate whenever multiple websites with different base currencies exist. With website price scope, a separate, manually maintained price can be stored per website, which effectively overrides the automatic rate conversion.
The choice between the two modes is a strategic decision that should be made early in the project, because switching price scope later requires a full reindex and often manual re-entry of all website-specific prices. For shops that genuinely need different price points per market, for example for competitive reasons, website price scope is the right approach. For shops that want consistent, rate-based prices across all markets, global price scope with automatic rate conversion is the lower-maintenance solution.
4. Rate import: cron, providers and custom connectors
Magento ships out of the box with a rate import mechanism via bin/magento currency:import, which fetches exchange rates from a configured provider, such as Fixer.io or Webservicex, and writes them into the directory_currency_rate table. For production shops this import should be automated through a cron job so that Magento 2 currency conversion always uses up-to-date rates instead of relying on stale, manually entered values. The cron job itself is configured under system/currency/cron and typically runs once daily.
For companies with their own treasury processes or special contractual rate agreements with banks, a custom import connector implementing the Magento\Directory\Model\Currency\Import\ImportInterface interface makes sense. This allows rates from internal systems, such as an ERP with a treasury module, to be fed directly into Magento without the detour through an external rate provider and its possible API limits or downtime.
#!/usr/bin/env bash
set -euo pipefail
# Run the native currency import for the configured provider
bin/magento currency:import
# Cron entry (crontab -e), matches system/currency/cron config
# 0 4 * * * /usr/bin/php /var/www/magento/bin/magento currency:import >> /var/log/magento/currency-import.log 2>&1
# Simple monitoring wrapper: alert if the import silently stopped updating
LOG_FILE="/var/log/magento/currency-import.log"
MAX_AGE_HOURS=30
if [[ ! -f "$LOG_FILE" ]]; then
echo "[ALERT] Currency import log missing entirely" >&2
exit 1
fi
last_modified=$(stat -c %Y "$LOG_FILE")
now=$(date +%s)
age_hours=$(( (now - last_modified) / 3600 ))
if (( age_hours > MAX_AGE_HOURS )); then
echo "[ALERT] Currency import log not updated for ${age_hours}h" >&2
exit 1
fi
echo "[OK] Currency import ran ${age_hours}h ago"
<?php
declare(strict_types=1);
namespace Mironsoft\CurrencyTools\Model\Currency\Import;
use Magento\Directory\Model\Currency\Import\AbstractImport;
use Magento\Framework\HTTP\Client\CurlFactory;
/**
* Custom currency import connector reading rates from an internal treasury API.
*/
final class TreasuryImport extends AbstractImport
{
private const string TREASURY_ENDPOINT = 'https://treasury.internal.example/api/rates';
/**
* @param CurlFactory $curlFactory HTTP client factory for the internal API
* @param array $data Additional constructor data passed by Magento
*/
public function __construct(
private readonly CurlFactory $curlFactory,
array $data = []
) {
parent::__construct($data);
}
/**
* Fetch conversion rates for the given currencies from the treasury API.
*
* @param string $currencyFrom Base currency code, e.g. "EUR"
* @param array $currenciesTo Target currency codes, e.g. ["USD", "GBP"]
* @return array Map of target currency code to conversion rate
* @throws \Magento\Framework\Exception\LocalizedException
*/
protected function _convert($currencyFrom, $currenciesTo)
{
$client = $this->curlFactory->create();
$client->get(self::TREASURY_ENDPOINT . '?base=' . $currencyFrom);
$rates = json_decode($client->getBody(), true);
$result = [];
foreach ($currenciesTo as $code) {
$result[$code] = $rates['rates'][$code] ?? 0.0;
}
return $result;
}
}
5. Rounding and price display without decimal chaos
A frequently underestimated detail of Magento 2 currency conversion is rounding. Not every currency uses two decimal places, Japanese yen for example has no decimal places at all, while some cryptocurrency extensions work with far more. Magento uses the PHP intl extension and the locale stored in the store view for formatting, correctly displaying thousand separators, decimal separators and decimal places, independent of the internal computed value.
A subtle bug occurs when custom calculations, for example in a price rule plugin, round the converted value directly instead of using Magento's PriceCurrencyInterface::round(). This method takes into account the actual decimal place configuration of the target currency and prevents decimal values from suddenly appearing in yen, a currency for which they simply do not exist. Anyone implementing custom price calculations should consistently use this method instead of a homegrown round() implementation.
6. Correctly limiting cart price rules per store view
Cart price rules in Magento can be restricted to specific websites and customer groups, but not directly to individual store views, since a rule technically operates at website level. For price rules per store that should genuinely apply only in one particular store view, for example a regional discount campaign for the Austrian market on a website shared with Germany, an extension via a custom sales_rule_validator plugin is needed that additionally checks the current store view.
A typical pattern here is a plugin on Magento\SalesRule\Model\Validator::canApplyRule() that extracts the allowed store view from a convention stored in the rule description, such as a prefix like [STORE:at], and applies the rule only if the current store view matches. This solution is pragmatic but should be well documented, since it introduces a convention outside the regular Magento backend that new editors could otherwise easily overlook.
<?php
declare(strict_types=1);
namespace Mironsoft\CurrencyTools\Plugin;
use Magento\SalesRule\Model\Validator;
use Magento\SalesRule\Model\Rule;
use Magento\Quote\Model\Quote\Address;
use Magento\Store\Model\StoreManagerInterface;
/**
* Restricts a cart price rule to a single store view via a naming
* convention in the rule description, e.g. "[STORE:at]".
*/
final class RestrictRuleToStoreView
{
/**
* @param StoreManagerInterface $storeManager Resolves the current store view
*/
public function __construct(
private readonly StoreManagerInterface $storeManager
) {
}
/**
* @param Validator $subject Native cart price rule validator
* @param bool $result Native validation result
* @param Rule $rule The rule being validated
* @param Address $address Quote address providing store context
* @return bool False if the rule is restricted to a different store view
* @throws \Magento\Framework\Exception\NoSuchEntityException
*/
public function afterCanApplyRule(
Validator $subject,
bool $result,
Rule $rule,
Address $address
): bool {
if (!$result || !preg_match('/\[STORE:([a-z_]+)]/', (string) $rule->getDescription(), $matches)) {
return $result;
}
$currentStoreCode = $this->storeManager->getStore()->getCode();
return $matches[1] === $currentStoreCode;
}
}
7. Tax classes and currency working together
Tax rules and Magento 2 currency conversion are technically independent systems, but together they affect the final displayed price. Tax calculation always happens first based on the base price in base currency, after which the tax-inclusive amount is converted into the display currency. This order matters, because a reversed order, converting first and then calculating tax, could lead with odd rates to minimally deviating final prices that are usually below one cent but, summed across many orders, cause rounding discrepancies in accounting.
For shops with US store views there is the added complication that tax rates there often differ by state or even by county, while currency conversion works uniformly across the whole store view. This combination of granular tax logic and more global currency logic requires particular care during configuration, especially when a tax class is mistakenly maintained at website level instead of at the more granular rule level.
8. Common pitfalls with rate changes
The most obvious pitfall in Magento 2 currency conversion is a failed or non-running cron job for the rate import. If the rate stays frozen for weeks while the real exchange rate shifts significantly, the shop either suffers unnecessary margin losses or the customer sees overpriced products compared to competitors. Monitoring that checks the timestamp of the last successful rate update and alerts when a threshold is exceeded is therefore practically mandatory for international shops.
A second pitfall concerns fixed discount amounts in price rules meant to apply across multiple store views with different display currencies. Since these amounts are defined in base currency and only converted for display, the perceived discount value shifts slightly with every rate update. For marketing campaigns with a clearly communicated, round discount amount in the respective local currency, it is often better to use percentage discounts instead of fixed ones, since percentages remain rate-independent and constant.
-- Detect currencies whose exchange rate has not been refreshed recently
SELECT currency_to, currency_from, rate
FROM directory_currency_rate
WHERE currency_from = 'EUR'
AND currency_to IN ('USD', 'GBP', 'CHF')
-- combine with an application-level check of the import timestamp,
-- directory_currency_rate itself has no built-in "last updated" column
;
-- Cross-check store base currencies against configured allowed currencies
SELECT s.code AS store_code, ccd.value AS base_currency
FROM store s
INNER JOIN core_config_data ccd
ON ccd.scope = 'stores'
AND ccd.scope_id = s.store_id
AND ccd.path = 'currency/options/base'
ORDER BY s.store_id;
9. Strategies compared: fixed rate versus live rate
Whether a shop works with daily updated live rates or with deliberately fixed, manually maintained rates depends heavily on the business model. The following table compares both strategies for Magento 2 currency conversion.
| Criterion | Live rate (daily import) | Fixed, manually maintained rate |
|---|---|---|
| Price stability for customers | Minimally fluctuates daily | Constant, predictable |
| Margin safety | Always close to market | Risk with strong rate movements |
| Maintenance effort | Automated via cron | Manual reviews needed |
| Suitability for price rules | Prefer percentage discounts | Fixed discount amounts plannable |
In practice most internationally operating shops opt for an automated rate import with a defined tolerance threshold within which prices stay stable, combined with a manual review process for larger rate swings. This hybrid strategy combines the automation of a live rate with the price stability of a fixed rate, without fully inheriting the drawbacks of either extreme.
Mironsoft
Magento 2 multi store and internationalization
Need currency conversion without margin or rounding risk?
We configure rate import, price scope and cart price rules for international Magento 2 shops so prices stay consistent per store view and discount campaigns work exactly as planned.
Rate automation
Set up cron-based import with monitoring and tolerance thresholds
Price rule audit
Review existing cart price rules for store scope issues
Custom connectors
Treasury or ERP rate integration as a custom import module
10. Summary
Magento 2 currency conversion works reliably when base currency and display currency are treated as separate concepts and the rate import runs automated and monitored. The price scope at website level fundamentally decides whether rates are applied automatically or replaced by manually maintained prices. Price rules per store that use fixed discount amounts should be aware of rate dependency and switch to percentage discounts where it makes sense.
Rounding should consistently run through Magento's own price utilities, not through custom implementations, to avoid decimal place errors with currencies such as yen. Anyone needing store-view-specific price rules beyond the native website restriction cannot avoid a custom validator plugin, but should document this extension cleanly so it remains understandable in day-to-day operations.
Magento 2 Currency Conversion — Key Takeaways
Base vs. display currency
Prices are stored in the website's base currency, converted to the store view display currency only for presentation.
Automate rate import
bin/magento currency:import via cron, with monitoring of the timestamp of the last successful update.
Check price rules for rate dependency
Fixed discount amounts shift with every rate update. Percentage discounts remain stable regardless of rate.
Rounding via PriceCurrencyInterface
Always use Magento's round() method so decimal places are handled correctly per target currency.