BW Multi-Currency

Version: 0.1.0 Author: Jubayer Hossain — Broodweb License: GPL-2.0-or-later Requires: WordPress 6.0+ · PHP 8.1+ · WooCommerce

BW Multi-Currency lets a WooCommerce store display and charge in more than one currency without duplicating a single product. You enter prices once in your base currency; the plugin converts everything WooCommerce renders — catalogue, variations, cart, shipping, coupons, checkout and the resulting order — into whichever currency the visitor is viewing.

Rates can be entered by hand or pulled automatically from a free exchange-rate provider on a schedule. Visitors can pick a currency themselves, or — when BW Multilingual is installed — the currency can follow the site language, so a Dutch page shows € and a Bangla page shows ৳ with no extra click.

The plugin is a companion to BW Multilingual and shares its conventions, but it runs perfectly well on its own.


1. How it works

One set of prices, many currencies

Your products keep exactly one price, stored in the base currency — the currency you author in, which should match your WooCommerce currency setting. Nothing in the database is duplicated or rewritten.

On each front-end request the plugin decides which currency the visitor should see, then hooks into WooCommerce’s price and formatting filters. Every amount WooCommerce is about to read is multiplied by the base → target rate on its way out, and the currency code, symbol, decimal count and separators are swapped to match. The storefront ends up fully consistent — a product page, the mini-cart and the checkout total all agree — while the stored data never changes.

When the active currency is the base currency, every callback returns its input untouched. There is no conversion overhead for visitors on the default currency.

Resolving the active currency

The active currency is decided once per request, on plugins_loaded at priority 9 — early enough that WooCommerce (priority 10) sees the filters before it renders anything.

The order of precedence depends on the sync mode:

Step Manual mode Hybrid mode Automatic mode
1 ?bwmc_currency= in the URL ?bwmc_currency= in the URL
2 bwmc_currency cookie bwmc_currency cookie
3 Geolocation (if enabled) Geolocation (if enabled)
4 Base currency Currency mapped to the site language Currency mapped to the site language

Once a visitor clicks the switcher, the choice is written to a cookie that lasts one month and the ?bwmc_currency= parameter is stripped from the URL with a redirect, so links stay clean and cacheable.

Contexts that never convert

Three contexts are forced to the base currency so stored data is never silently rewritten:

  • WP-Admin screens — order editing, reports and product pricing always show base-currency figures.
  • WP-Cron — scheduled jobs run in base currency.
  • Any request where the plugin is not on the storefront.

Front-end AJAX and REST (Store API) requests do resolve normally, because the block cart and checkout must show the same currency as the page the visitor is on.

Standalone or paired

Everything related to language sync degrades gracefully. If BW Multilingual is not active, the bridge reports as inactive, the Sync tab explains why it’s unavailable, auto and hybrid modes silently behave as manual, and the combined switcher falls back to the currency-only switcher. Nothing errors, nothing is half-configured.


2. Installation

Requirements

Requirement Minimum
WordPress 6.0
PHP 8.1
WooCommerce Required for price conversion
BW Multilingual Optional — needed only for language sync

WooCommerce is what the price engine hooks into. Without it, the plugin activates and the settings screen works, but nothing is converted.

Steps

  1. Upload the bw-multicurrency folder to /wp-content/plugins/, or install the ZIP from Plugins → Add New → Upload Plugin.
  2. Activate it from the Plugins screen.
  3. Open Multi-Currency in the admin menu.

On first activation the plugin reads your WooCommerce currency setting and adopts it as the base currency, seeding the enabled list and a 1.0 rate for it. If you install before WooCommerce, the base defaults to USD and you’ll want to correct it on the Currencies tab.

Deactivating clears the rate-refresh cron event. Your settings are kept, so reactivating picks up exactly where you left off.


3. Quick start

Five minutes, in order:

  1. Multi-Currency → Currencies. Confirm the base currency matches your WooCommerce currency — a mismatch shows a warning notice, because prices would otherwise convert from the wrong starting point. Then tick the currencies you want to offer. The base is always enabled and can’t be unticked.
  2. Rates. Choose Automatic to fetch live rates on a schedule, or Manual to type them in. Even in automatic mode it’s worth filling in manual rates for anything your provider might not cover — they act as the fallback.
  3. Sync (only meaningful with BW Multilingual active). Pick how currency relates to language:
    • Manual — the two are independent.
    • Automatic — currency follows the language and the currency switcher disappears.
    • Hybrid — language sets the default, visitors can still override it.
  4. Switcher. Turn on the floating pill, or drop [bwmc_switcher] into a menu, widget or template.
  5. Test. Load the shop as a logged-out visitor, switch currency, add something to the cart and walk through checkout. Confirm the mini-cart, shipping and total all follow.

4. Currencies

The registry

The plugin ships with a catalogue of 43 currencies, keyed by ISO 4217 code so the keys line up with WooCommerce’s own currency codes. Each entry carries everything needed to render an amount correctly:

Field Purpose
name English display name
symbol Currency symbol (, , RM, …)
flag Emoji flag, rendered as an SVG image
decimals Decimal places (0 for JPY, KRW, VND, IDR, HUF)
position left, right, left_space, right_space
thousand Thousands separator for that locale
decimal Decimal separator for that locale

Coverage spans the Americas, Europe, the Middle East, Africa, South Asia and East Asia — including BDT, INR, PKR, LKR, NPR, NGN, KES and EGP, which many multi-currency plugins omit.

The catalogue is filterable, so you can add a currency or override a symbol without touching plugin files. See Developer reference.

Base currency

The base currency is the one your prices are entered in. It should always match WooCommerce → Settings → General → Currency. If the two drift apart, the admin screen shows a warning, because every conversion starts from the base and a mismatch means every displayed price is wrong by the size of one rate.

Changing the base after go-live invalidates any fetched live rates — they’re stored with the base they were fetched against and are ignored when that base changes, so the next refresh re-fetches cleanly.

Enabled currencies

Enabled currencies are what visitors can switch to. The base is always included, and any currency you disable is immediately dropped from the switcher, the language map and gateway restrictions on the next render — a stale mapping can never surface a currency you no longer offer.

Flag rendering

Flag emoji are output as SVG images from WordPress.org’s emoji CDN rather than as raw characters. Windows has no flag-emoji font and would otherwise show bare letter pairs (“NL” instead of 🇳🇱). The emoji itself stays as the alt text, so a blocked or failed image degrades to the character.


5. Exchange rates

A rate is always expressed as how many units of the target currency equal one unit of the base. If your base is USD and EUR sits at 0.92, a $100 product shows as €92.

Manual rates

Type the rates in on the Rates tab. They’re stored with the rest of your settings, never change on their own, and are exactly what a store wants when it prices deliberately rather than tracking the market — a round €89 rather than €87.43.

Manual rates also serve as the safety net in automatic mode: if the provider doesn’t return a rate for a currency, the manual one is used instead.

Automatic rates

In automatic mode a provider is called on a schedule and the results are stored separately from your settings, along with a timestamp, the provider used, and the last error.

Two providers ship with the plugin, both free and neither requiring an API key:

Provider Source Coverage Best for
ER-API (default) open.er-api.com ~160 currencies Most stores — includes BDT, NGN, LKR and other currencies ECB doesn’t publish
Frankfurter api.frankfurter.dev ~30 major currencies Stores that want central-bank sourced numbers (official ECB reference rates)

Choose the refresh interval — hourly, twice daily or daily. The cron event is kept in step with your settings automatically: switching to automatic schedules it, switching back to manual clears it, and changing the interval reschedules it.

Save & refresh now on the Rates tab saves your changes and immediately fetches, so you can verify a provider works without waiting for cron.

Markup

The markup percentage (from -50% to +100%) is added to fetched rates only — never to manual ones. It exists to absorb payment-processor FX spread and rate drift between refreshes: a 2% markup means a rate that moves 1.5% against you between daily fetches still doesn’t cost you margin.

Failure behaviour

A failing provider never wipes what you have. If a fetch errors, the stored rates are left alone and only the error message and timestamp are recorded — the storefront keeps converting with the last good numbers, and the error appears in the status line under the Rates tab.

Similarly, results are merged rather than replaced, so a provider that omits one currency (Frankfurter and BDT, for example) doesn’t drop a rate a previous fetch obtained. Any currency the provider didn’t cover is listed in the status line so you know to supply a manual rate for it.

The rates table

The Rates tab shows three columns per currency so you can always see what’s actually in effect:

Column Meaning
Manual rate What you typed in
Live rate What the provider last returned (before markup)
Effective The rate actually used for conversion right now

Rounding

Three rounding rules, set on the Advanced tab, applied after conversion:

Mode Behaviour Example
Exact Round to the currency’s own decimal count 55.204 → 55.20
Nearest whole Round to the nearest integer 55.20 → 55
Round up Always round up to the next integer 55.20 → 56

Whole-number rounding suits currencies where decimal amounts look strange — a price of ৳6,047.32 reads better as ৳6,048 — and produces cleaner numbers than a raw conversion ever will.


6. Language sync

This section applies when BW Multilingual is active. Without it, the Sync tab explains that the plugin is running standalone and the mode stays effectively manual.

The three modes

Manual — currency and language are unrelated. A visitor reading in Dutch can browse in dollars if they want. The currency switcher is visible.

Automatic — currency is a pure function of the language. Switching to Dutch switches to euros; there is nothing for the visitor to choose, so the currency switcher renders nothing at all. This is the cache-friendliest mode, because every visitor on a given URL sees the same currency.

Hybrid — the language decides the default, but a visitor who explicitly picks a currency keeps that choice. The best of both for stores whose language regions don’t map perfectly onto currency preferences.

The mapping table

The Sync tab lists every active BW Multilingual language with a currency dropdown and the rate currently in effect. Each language can be set to:

  • Auto — follow the locale-based suggestion (shown in the dropdown label so you can see what it resolves to).
  • Base — pin this language to the base currency explicitly.
  • A specific currency — an explicit override.

How Auto suggests

Auto works from the language’s locale, not guesswork:

  1. Take the country part of the locale — nl_NLNL → EUR.
  2. If the locale has no country part, fall back to a language-code table — bn → BDT, ja → JPY, tr → TRY, and so on.
  3. If neither yields a result, or the suggested currency isn’t enabled, use the base currency.

The euro is mapped across all twenty eurozone countries, so nl_NL, de_DE, fr_FR, es_ES, pt_PT and the rest all resolve to EUR without any configuration.

Because Auto is evaluated on each request rather than baked in, enabling a new currency later activates it for its languages immediately — you don’t have to revisit the mapping table. If a language’s suggestion points at a currency you haven’t enabled, the tab tells you so directly under the dropdown.

Language sync and the Store API

Block cart and checkout call the Store API over REST, and REST URLs carry no language prefix — so BW Multilingual would resolve them to the default language. The bridge handles this by reading the request’s Referer header, which points at the localized page that made the call, exactly as BW Multilingual does for its own AJAX. The block checkout therefore stays on the visitor’s currency instead of snapping back to the default.


7. The switcher

Shortcodes

[bwmc_switcher]
[bwmc_switcher style="inline" show="symbol_code"]
Attribute Values Default
style dropdown, inline From settings
show symbol, code, flag, flag_code, symbol_code, name From settings

The switcher renders nothing at all in two cases: when the sync mode is Automatic (there’s nothing to choose), and when fewer than two currencies are enabled.

The combined switcher

[bwmc_combined_switcher]
[bwmc_combined_switcher style="inline"]

With BW Multilingual active, this renders a single control showing both language and currency — 🇳🇱 NL · € — where each item is a language whose link carries both the localized URL and that language’s mapped currency. One click switches both.

In automatic sync mode the link omits the currency parameter, because the currency follows the language on its own. Without BW Multilingual, the combined switcher falls back to the ordinary currency switcher.

If you use the combined switcher, turn off BW Multilingual’s own floating switcher — otherwise you’ll have two pills stacked in the same corner.

The floating pill

Set on the Switcher tab, output automatically in the footer:

Option Result
Off No floating switcher
Currency — bottom right / left Currency-only pill
Language + currency — bottom right / left Combined pill (requires BW Multilingual)

Appearance

The appearance controls mirror BW Multilingual’s exactly, so the two switchers can sit side by side and look like one control family:

Setting Range Default
Font size 8–40 px 14
Flag size 8–48 px 18
Pill background Hex colour #7c4dff
Pill text Hex colour #ffffff
Menu background Hex colour #ffffff
Menu text Hex colour #182b2f

CSS and JavaScript are both inlined — no extra HTTP requests. The dropdown closes on outside click and on Escape, and the toggle carries aria-haspopup and aria-expanded for screen readers.

Styling hooks

Class Element
.bwmc-switcher Wrapper
.bwmc-switcher--dropdown / --inline Layout variant
.bwmc-switcher--floating / --left Floating pill and its side
.bwmc-current The dropdown toggle
.bwmc-flag, .bwmc-symbol, .bwmc-code, .bwmc-name Label parts
.is-current The active currency’s link
.is-open Open dropdown state

8. What gets converted

Prices

Every product and variation price filter WooCommerce exposes is hooked — price, regular price and sale price, at both product and variation level. Variation price ranges are cached by WooCommerce, so the plugin adds the active currency to the cache key; without this, a EUR visitor could be served a USD range from cache.

Empty values are preserved rather than converted. WooCommerce uses an empty string to mean “no price” or “no sale price”, and casting that to zero would turn an unpriced product into a free one.

Shipping

Shipping rates are converted after the shipping methods have calculated them, including their tax components. The shipping package is tagged with the active currency first, so WooCommerce’s own rate cache is not shared between currencies.

Cart fragments

The mini-cart fragment is cached in the browser’s sessionStorage under a hash key. The plugin salts that key with the active currency, so switching currency never leaves a stale mini-cart total sitting in the header.

Coupons

Coupon type Behaviour
Fixed cart / fixed product discount Converted from base to active currency
Percentage discount Untouched — a percentage is currency-agnostic
Minimum spend Converted
Maximum spend Converted

This matters more than it first appears: a “$50 minimum spend” coupon that wasn’t converted would be trivially easy to qualify for in one currency and nearly impossible in another.

Number formatting

Alongside the amount, the plugin swaps the currency code, symbol, decimal count, decimal separator, thousands separator and symbol position — all read from the currency registry. A euro price renders as € 1.234,56 and a Swedish one as 1 234,56 kr, each following its own local convention rather than inheriting the store’s.


9. Payments and orders

Orders are charged in the displayed currency

When a visitor checks out in euros, the order is created in euros. This is the honest behaviour — the customer pays what the page said — but it has a direct consequence: your payment gateways must support every currency you enable.

Restricting gateways by currency

The Advanced tab lists every enabled WooCommerce gateway with a checkbox per currency:

  • Nothing checked — the gateway is available for all currencies (the default).
  • Specific currencies checked — the gateway is hidden at checkout for any other currency.

This is how you handle a local processor that only settles in one currency: check BDT for it, and visitors browsing in euros simply won’t see it as an option.

The order audit trail

Every converted order is stamped with three pieces of meta, for both classic and block checkout:

Meta key Contents
_bwmc_base_currency The base currency at the time of the order
_bwmc_rate The exact rate the order was priced at
_bwmc_base_total The order total converted back to base currency

The order edit screen renders this as a readable line beneath the order details — “charged in EUR at rate 1 USD = 0.92. Base value ≈ 108.70 USD” — so reconciling a month of mixed-currency orders doesn’t require guessing which rate was in effect on which day.

Orders in the base currency are not stamped, since there’s nothing to record.


10. Caching

Currency selection and full-page caching are in natural tension: a cache stores one copy of a URL, but two visitors on that URL may need different prices.

The plugin resolves this precisely rather than bluntly. On each request it works out what an uncookied visitor would see on that URL. If the current visitor’s currency differs — because of a cookie or a geolocation result — the response defines DONOTCACHEPAGE, which BW Multilingual’s page cache and essentially every WordPress caching plugin honour. That single visitor’s response is excluded; everyone else still gets a cached page.

In Automatic sync mode this never triggers at all. Currency is a function of the language, the language is a function of the URL, and every visitor on a URL sees the same thing — so caching stays fully enabled. If page-cache hit rate matters to you and your languages map cleanly onto currencies, automatic mode is the mode to choose.

Two events flush BW Multilingual’s per-language page cache automatically, since both change every rendered price: saving currency settings, and a successful live-rate refresh.


11. Settings reference

All settings live in a single option, bwmc_settings. Fetched live rates are stored separately in bwmc_auto_rates.

Currencies tab

Setting Key Default
Base currency base_currency WooCommerce currency, or USD
Enabled currencies enabled_currencies [base]

Rates tab

Setting Key Values Default
Rate source rate_mode manual, auto manual
Provider rate_provider erapi, frankfurter erapi
Refresh interval rate_refresh hourly, twicedaily, daily daily
Markup rate_markup -50 to 100 (%) 0
Manual rates rates ISO code → float []

Sync tab

Setting Key Values Default
Resolution mode sync_mode manual, auto, hybrid manual
Language map lang_currency_map lang → '' (auto) / BASE / ISO code []

Switcher tab

Setting Key Values Default
Shortcode style switcher_style dropdown, inline dropdown
Shortcode label switcher_show symbol, code, flag, flag_code, symbol_code, name flag_code
Floating switcher switcher_floating '', floating, floating-left, combined, combined-left ''
Floating label switcher_float_show as above symbol_code
Font size sw_font_size 8–40 14
Flag size sw_flag_size 8–48 18
Pill background / text sw_pill_bg / sw_pill_text hex #7c4dff / #ffffff
Pill radius sw_pill_radius 0–100 100
Menu background / text sw_menu_bg / sw_menu_text hex #ffffff / #182b2f
Menu radius / width sw_menu_radius / sw_menu_width 0–40 / 0–600 8 / 0 (auto)

Advanced tab

Setting Key Values Default
Price rounding rounding none, nearest, up none
Geolocation default geo_default boolean false
Gateway currencies gateway_currencies gateway id → ISO codes ([] = all) []

Geolocation

When enabled, a first-time visitor with no cookie gets a currency derived from their country, using WooCommerce’s own geolocation — server geo headers or the local MaxMind database. No external API is called, so it adds no latency to the request.

It applies only when the language hasn’t already decided a currency, only for enabled currencies, and only once — the result is written to the cookie, so subsequent requests skip the lookup. An explicit visitor choice always wins.


12. Developer reference

Filters

Filter Arguments Purpose
bwmc_currencies $list Add, remove or modify currency definitions
bwmc_active_currency $code Override the resolved currency for a request
bwmc_rate $rate, $to, $base Override a single conversion rate
bwmc_rate_providers $providers Register additional exchange-rate providers

Actions

Action Arguments Fires
bwmc_loaded $plugin After the plugin boots
bwmc_rates_refreshed $rates, $provider_id After a successful live fetch

Adding a currency

add_filter( 'bwmc_currencies', function ( $list ) {
    $list['ISK'] = [
        'name'     => 'Icelandic Króna',
        'symbol'   => 'kr',
        'flag'     => '🇮🇸',
        'decimals' => 0,
        'position' => 'right_space',
        'thousand' => '.',
        'decimal'  => ',',
    ];
    return $list;
} );

Adding a rate provider

Extend BWMC_Provider and register the instance. The base class gives you a get_json() helper with error handling already built in.

class My_Rate_Provider extends BWMC_Provider {

    public function id() {
        return 'my-provider';
    }

    public function label() {
        return 'My Provider';
    }

    public function fetch( $base, array $targets ) {
        $data = $this->get_json( 'https://example.com/rates?base=' . rawurlencode( $base ) );
        if ( is_wp_error( $data ) ) {
            return $data;
        }

        $out = [];
        foreach ( $targets as $code ) {
            if ( isset( $data['rates'][ $code ] ) && (float) $data['rates'][ $code ] > 0 ) {
                $out[ $code ] = (float) $data['rates'][ $code ];
            }
        }
        return $out;
    }
}

add_filter( 'bwmc_rate_providers', function ( $providers ) {
    $providers[] = new My_Rate_Provider();
    return $providers;
} );

fetch() must return an array of code => rate (the value of one base unit) or a WP_Error. Returning a WP_Error preserves the last good rates and surfaces your message in the admin status line.

Reading state in your own code

BWMC_Resolver::current();              // Active currency code
BWMC_Resolver::is_base();              // Is the active currency the base?
BWMC_Resolver::mode();                 // manual | auto | hybrid (effective)
BWMC_Settings::base_currency();        // Base currency code
BWMC_Settings::enabled_currencies();   // All enabled codes, base first
BWMC_Rates::rate( 'EUR' );             // Effective base → EUR rate
BWMC_Rates::convert( 49.00, 'EUR' );   // Convert a base amount
BWMC_Currencies::get( 'EUR' );         // Full currency definition
BWMC_Bridge::active();                 // Is BW Multilingual available?

Classes

Class Responsibility
BWMC_Plugin Singleton orchestrator; wires everything on boot
BWMC_Settings The single settings option, with sanitization
BWMC_Currencies The currency registry and label helpers
BWMC_Resolver Decides the active currency per request
BWMC_Rates Rate lookup, conversion and rounding
BWMC_Rate_Service Scheduled live-rate fetching and storage
BWMC_Provider Provider base class and registry
BWMC_Bridge Adapter to BW Multilingual and geolocation
BWMC_Price_Engine All WooCommerce filters
BWMC_Switcher Shortcodes, styles and the floating pill
BWMC_Admin The settings screen and order note

Storage

Type Name Contents
Option bwmc_settings All configuration
Option bwmc_auto_rates Fetched rates, base, timestamp, provider, last error
Cookie bwmc_currency Visitor’s chosen currency (1 month, HttpOnly)
Query arg bwmc_currency Switch trigger, stripped by redirect
Cron event bwmc_refresh_rates Live-rate refresh
Order meta _bwmc_base_currency, _bwmc_rate, _bwmc_base_total Conversion audit trail

The plugin creates no database tables.


13. Troubleshooting

Prices aren’t converting at all. Check that WooCommerce is active — the price engine only registers its filters when it is. Then confirm you have at least one non-base currency enabled and a rate set for it.

A currency shows the same numbers as the base. That currency has no rate. When no manual rate is set and the provider hasn’t returned one, conversion falls back to 1:1 rather than pricing at zero. Set a manual rate on the Rates tab, or check the status line for a provider error.

The switcher doesn’t appear. Three possible reasons: the sync mode is Automatic (the switcher is hidden by design — the currency follows the language), fewer than two currencies are enabled, or the shortcode is in a context that doesn’t run shortcodes.

A warning says my base currency doesn’t match WooCommerce. Set them the same. Whichever one is wrong, correct it — the base currency is the starting point for every conversion, so a mismatch makes every displayed price wrong by one rate.

Live rates aren’t updating. Check the status line under the Rates tab, which shows the provider, interval, when rates were last fetched and any error. Press Refresh now to fetch immediately and see the result. If cron never fires, your host may have DISABLE_WP_CRON set — wire up a real system cron hitting wp-cron.php.

Frankfurter returns an error for one of my currencies. Frankfurter serves ECB reference rates, which cover roughly thirty major currencies. BDT, NGN, LKR and similar aren’t among them. Switch to ER-API, or keep Frankfurter and add manual rates for the currencies it doesn’t cover.

Cached pages show the wrong currency. Confirm your caching layer honours DONOTCACHEPAGE. If it doesn’t, use Automatic sync mode, which is fully cache-safe because currency depends only on the URL.

Checkout offers a gateway that can’t take the currency. Restrict it on the Advanced tab — check only the currencies that gateway supports.

Old prices persist after a rate change. Saving settings and a successful rate refresh both flush BW Multilingual’s page cache. Third-party caching plugins and CDNs need their own flush.


14. Uninstalling

Deactivating clears the rate-refresh cron event and leaves everything else intact — settings, fetched rates and order meta all survive, so reactivation restores your configuration exactly.

Deleting the plugin through WordPress removes the bwmc_settings option. Order meta is deliberately preserved, since it’s part of your financial record and deleting it would break historical reconciliation.

Prices return to your WooCommerce currency immediately. Nothing in your product data was ever modified, so there is no migration to undo.


BW Multi-Currency is developed by Broodweb.

Was this article helpful to you?

Nieuwsbrief

Blijf op de hoogte

Ontvang meldingen over nieuwe plugins, updates en exclusieve tutorials.

Geen spam ooit. U kunt zich op elk moment uitschrijven.