MHM Currency Switcher adds multi-currency support to your WooCommerce store. Customers can browse products, add items to their cart, and complete checkout in their preferred currency with real-time exchange rates.
Key Features
Cache compatibility mode
A page cache stores the HTML your server produced for whoever asked first. When
prices are converted on the server, that means the first visitor’s currency is
what every later visitor is served. Cache compatibility mode, which is on by
default, avoids this: anonymous shop, archive and product pages are rendered in
your base currency, so the same cached page is correct for everyone, and the
browser converts the prices it can see afterwards through a REST request to this
plugin.
The split is deliberate. Only displayed prices are converted in the browser.
Cart, checkout, order totals, order emails and the WooCommerce REST API are
always calculated on the server in the currency the customer actually chose, so
the amount charged cannot be altered from the browser.
You can switch the mode off under WooCommerce > MHM Currency >
Advanced, in which case prices are converted on the server as they were before
this feature existed. Read “Known limits” below before deciding either way —
both settings have consequences, and they are different ones.
These are consequences of how cache compatibility mode works, not defects. They
are listed so you can decide with your eyes open.
Three fixes sit above the setting and stay in place whether it is on or off:
prices are no longer converted on admin screens or in admin AJAX (which used to
write a converted price into an order line item), wc/v3 REST reads are pinned
to the base currency, and scheduled tasks and WP-CLI no longer convert. Switching
the mode off restores the 1.0.0 display behaviour — prices converted on the
server — and nothing else.
With the mode on, the page a crawler fetches has not been through the browser,
so it carries base-currency prices, and so does the machine-readable product data
in it. See the structured data question above; the mismatch is deliberate.
The page arrives with your base-currency prices already on screen — nothing is
hidden waiting for JavaScript — and the browser swaps in the converted ones as
soon as its request comes back, each price fading over 200ms as it changes. On a
slow connection the base price is readable for longer before the swap. Visitors
who have asked their system for reduced motion get the swap without the fade.
Nothing breaks and no error is shown to the visitor — the page simply keeps the
base-currency prices it was rendered with, and the reason is written to the
browser console. Cart and checkout are unaffected, because they never depended
on the browser in the first place.
On a cacheable page the plugin forces WooCommerce to fetch variation prices over
AJAX, because the variations JSON WooCommerce would otherwise embed in the page
carries base-currency prices that its own scripts write straight into the page.
The cost is one request when a visitor picks a variation, and that
data-product_variations is false: third-party colour or size swatch plugins
that read prices out of that JSON instead of asking WooCommerce may stop showing
a price. If you use such a plugin, check a variable product before going live.
A mini-cart is rendered on every page, so it cannot be classified per request.
It is rendered in the base currency and then corrected by WooCommerce’s own cart
fragment refresh, which is a server-side conversion. If cart fragments are
disabled on your site — some themes and optimisation plugins dequeue them — the
cached mini-cart total stays in the base currency while the rest of the page
converts. The plugin watches for this and says so in the admin when it happens;
a site with no mini-cart at all is never warned about it.
Page caches exclude cart and checkout automatically because they recognise the
pages WooCommerce assigned. If you have put a cart or checkout shortcode or block
on some other page, exclude that page yourself. Two things go wrong otherwise:
the conversion decision flips to “convert” partway through the render, so the
rest of the page is printed already converted and without the markers the browser
looks for, and blocks-based cart and checkout embed their amounts in the page as
JSON while rendering. Either way the first visitor’s currency is what the cache
then hands to everyone. Cart contents are personal anyway; such a page should not
be cached.
A currency can be requested in the URL, and a cache treats every distinct URL as
a separate entry, so linking to ?currency=EUR and ?currency=GBP stores the
same page more than once. The switcher itself does not produce these URLs — it
writes a cookie and leaves the address alone. On a cached page it converts the
prices where they stand; on the cart page, for a logged-in visitor, or with
cache compatibility switched off, it reloads instead. Either way the URL is the
one the visitor was already on, so no extra cache entry is created.
A ?currency= link also applies to that page view only: it deliberately sets no
cookie, so the next page the visitor opens is back in your base currency unless
they use the switcher. That is not an oversight — a link that silently pinned a
currency could show one currency in the catalogue while the cart, which reads
the cookie, charged another. If you want a campaign link that sticks, send
visitors to a page carrying the switcher rather than relying on the parameter.
An order is stored in the currency the customer paid in, and WooCommerce
Analytics reports every order’s figures in your store currency without
converting them back. A 4.38 USD order is counted as 4.38 in your base
currency, so once you take orders in more than one currency the revenue
figures in Analytics, and the totals in the customer panel on the order
screen, are sums of unlike amounts. The orders themselves are correct — each
one keeps its own currency, total and the exchange rate it was placed at, and
this plugin stores that rate on the order. It is the aggregate reports that
cannot be read as money. Nothing in this plugin can fix that from the outside;
if you need accurate multi-currency reporting, export the orders and convert
them using the rate recorded on each one.
Logged-in visitors take the server-side path, which is correct as long as your
cache does what nearly all of them do and never serves cached pages to logged-in
users. An edge cache or CDN configured to cache without looking at cookies is the
exception, and there a logged-in visitor’s converted page can be stored and
served on. If you cache at the edge, confirm it varies on the login cookie.
This plugin connects to two third-party services to keep currency conversion
rates up to date. What each one is sent, and when, is described separately
below because the two are not the same. Both requests are made with PHP’s
WP_Http transport, which in WordPress’s default configuration sends a
User-Agent header of the form WordPress/{version}; {your site's URL} —
so the site’s own address leaves with every request to either service, not
just the data described below. That header is filterable
(http_headers_useragent, http_request_args), so a site that has changed
it will send something different.
ExchangeRate-API
What it is: a commercial exchange-rate API, used as the primary source of
exchange rates.
What is sent, and when: the three-letter base currency code you have
configured (for example USD), sent as part of the request URL —
https://api.exchangerate-api.com/v4/latest/{BASE_CURRENCY} — when you
press “Sync rates” in the admin panel, when you run wp mhmcs rates-sync
from the command line, and on the schedule you configure under automatic
rate updates (hourly, twice daily, or daily). No other data from your site
is included.
Terms of service and privacy policy: https://www.exchangerate-api.com/terms
(ExchangeRate-API publishes its privacy policy inside that same page rather
than on a separate one.)
European Central Bank (ECB) daily reference rates
What it is: the ECB’s public daily reference-rate feed, used as the fallback
when ExchangeRate-API cannot be reached.
What is sent, and when: nothing beyond the User-Agent described above. The
feed is a fixed, parameter-free address —
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml — so no
currency code or other value is sent to the ECB; the same document is
returned to every requester. It is only requested when ExchangeRate-API’s
request has failed. The feed is EUR-based and covers roughly thirty
currencies rather than the hundreds ExchangeRate-API carries. If your base
currency is outside that set, this source returns nothing at all and your
existing rates are left unchanged until the next attempt; if only one of
your configured target currencies is outside that set, the other target
currencies still update and the unsupported one is simply left without a
rate from this source.
The ECB does not publish a document titled “Terms of Service.” Its terms of
use are stated on its Disclaimer & Copyright page, which is the closest
equivalent and is linked below.
Disclaimer & Copyright (terms of use): https://www.ecb.europa.eu/services/using-our-site/disclaimer/html/index.en.html
Privacy statement: https://www.ecb.europa.eu/services/data-protection/privacy-statements/html/ecb.privacy_statement_website.en.html
The ECB’s reference-rates page separately states that using these rates for
transaction purposes is strongly discouraged:
https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html
That page, not the two links above, is the source of that caution. This
plugin uses the feed to convert prices for display and checkout, which is
the kind of transactional use that notice is about; if that matters for your
shop, review that page before relying on this fallback.
Redirecting a source
If your network blocks the ECB feed, the mhmcs_fallback_rates_url filter
receives the URL, the base currency code and which source is being filtered
— always 'ecb', since ECB is now the only fallback — so the request can
be pointed elsewhere.
Visitor geolocation (through WooCommerce)
When “Enable geolocation-based currency detection” is switched on, the plugin
asks WooCommerce which country a visitor is in, using WooCommerce’s own
WC_Geolocation API. Depending on how your site is configured, WooCommerce
answers that either from a local MaxMind database or by contacting the remote
geolocation service it is configured to use — the request and the service are
WooCommerce’s, not this plugin’s, and this plugin sends nothing itself. The
setting is off unless you turn it on.
WooCommerce geolocation documentation:
https://woocommerce.com/document/maxmind-geolocation-integration/
The settings screen is a React application, and what ships inside the plugin is
the compiled bundle at admin-app/build/index.js. The readable source it is
built from is not in the package, so here is where to find it and how to
reproduce the build.
Full source, including the unminified JavaScript:
https://github.com/MaxHandMade/mhm-currency-switcher
The source of the bundle is admin-app/src/. It is compiled with WordPress’s
own build tooling, @wordpress/scripts, and nothing else:
npm install
npm run build
That writes admin-app/build/index.js together with the index.asset.php
dependency map the plugin reads when enqueuing the script. No other build step,
minifier or bundler is involved, and no code is generated at install time or at
runtime.