Magento 2 Currency Conversion: Getting Per-Store Price Rules Right
AI generated
M2
di.xml
Magento 2 · Multi Store · Multi Currency
Magento 2 Currency Conversion
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.

19 min read Base currency · Rate import · Cart price rules Magento 2.4.x

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.

11. FAQ: Magento 2 Currency Conversion and Price Rules

1Base currency vs. display currency?
Base currency is the website's accounting currency, display currency is the per-store-view converted presentation of it.
2How do I automate rate import?
bin/magento currency:import via cron from system/currency/cron, provider configured under currency/options/import_service.
3Why does the discount change per store view?
Fixed amounts are defined in base currency and converted only for display, so value shifts with rate changes.
4What does catalog/price/scope control?
Whether there is one global auto-converted price or a separate manually maintained price per website.
5Limit a price rule to one store view?
Only via a custom plugin on Validator::canApplyRule(), since native rules only know website scope.
6Why use PriceCurrencyInterface::round()?
Accounts for the correct decimal place configuration of the target currency, e.g. zero for yen.
7Fixed amounts or percentages for discounts?
Percentages are rate-independent and therefore usually more stable for international campaigns.
8How do I detect a failed rate import?
Via the timestamp in directory_currency_rate or via custom monitoring with alerting.
9Can I use a custom rate provider?
Yes, via a custom class extending AbstractImport, registered as an additional option.
10Switching from global to website price scope?
Requires a full reindex and manual re-entry of all website-specific prices.