FOX
A WooCommerce plugin that shows prices in the currency the visitor picks and can optionally take payment in it.
- First release
- Active installs
- 50,000+
- Rating
- Read reviews
About
FOX converts the amounts WooCommerce produces into the currency a visitor selects, and can let the customer pay in that currency. WooCommerce knows only one store currency, so the plugin works by intercepting product price getters, shipping rates, coupons, cart totals, order rendering and emails, and converting each amount once at a stored rate.
The difficulty is that one price travels through many WooCommerce code paths: some are cached, some run in background requests, some run in wp-admin. Each path has to see the right currency. The storefront uses the visitor's currency, emails and the order editor use the order's currency, and exports, search engine markup and analytics use the base currency. The premium edition also keeps the WooCommerce REST API and webhook payloads in the base currency.
Around that core sit rate import from 15 sources with its own scheduler and sanity limits, per-visitor currency storage that works behind full-page caches, per-currency rounding and number formats, manual prices per currency, country and user role, and recalculation of stored orders between currencies on both HPOS and legacy order storage.
The free edition on wordpress.org runs the same main class, limited to two currencies in get_currencies().
FOX · currency switcher
what was solved
Price conversion inside WooCommerce
shipped · 2014What it givesEvery price is converted exactly once, so customers never see doubled or wrong amounts. The owner can set fixed prices per currency, country or customer role.
- Problem
- WooCommerce has one store currency and reads prices through many getters, caches and HTML builders. Every amount must be converted exactly once, and some amounts must not be converted at all.
- Solution
One converter sits on the product price getters at priority 9999. Manual prices take precedence over rate conversion, and explicit bypass switches exist for amounts that are already final.
- Stack
Convert simple and variation prices once, at the data layerHook the product and variation price getters with one converter at priority 9999
In the mode where the customer pays in the selected currency, raw_woocommerce_price() is attached to woocommerce_product_get_price, the regular and sale getters and the variation getters. Running at 9999 puts it after other plugins that adjust the base price. In display-only mode only the raw_woocommerce_price filter is used, so stored cart and order amounts stay in the base currency.
Edge cases handled in the converter: empty prices pass through untouched, a variable parent queried with display=false is skipped to avoid converting min/max twice, and the sale getter returns an empty string when no sale price is stored. Without that, a converted empty sale price becomes 0 and WooCommerce shows a crossed-out price.
Variation price cache shared between all currenciesAdd the current currency to the variation prices hash so each currency gets its own entry
WooCommerce caches the computed price arrays of a variable product in a transient keyed by a hash of the pricing inputs. If the currency is not part of that hash, the first visitor's converted prices are served to everyone. woocommerce_get_variation_prices_hash() appends the current currency.
woocommerce_variation_prices() then converts each entry, applies per-variation fixed prices, and re-sorts every array with asort(). The price range is read from the first and last element, and fixed prices per currency can change the order of variations compared with the base currency.
Manual prices per currency, country and role in one pipelineA fixed currency price is final; a country or role price replaces the base price before rate conversion
Fixed prices are stored as product meta per field and currency (_woocs_regular_price_EUR, _woocs_sale_price_EUR), with -1 meaning 'not set, use the rate'. A fixed price in the visitor's currency is used as is. Otherwise a country price replaces the base price, and the result is converted by the rate. Country prices group countries into blocks with one price per block, and the visitor's country comes from WooCommerce geolocation stored in the visitor state.
Role prices are applied after that step. When a price is set for the user's role, it replaces the price chosen so far, including a fixed currency price, and is converted by the rate.
When a sale price per country is left empty, get_product_geo_data() copies the regular price into it, otherwise the product would become free in that country. When a fixed sale field is empty, the lookup falls back to the fixed regular field.
A price filter gets a number, not the name of the price fieldMatch the value against stored regular and sale prices, then fall back to a request memo
Fixed prices are stored per field, but woocommerce_product_get_price passes only a number. get_price_type() compares the incoming value with the product's raw regular and sale prices read in 'edit' context. If neither matches, it keeps a static map of the first price seen for each product in the request and treats a later, larger value as the regular price.
This is a heuristic, so explicit field types are passed wherever the caller knows them. Role prices use their own copy of the same logic.
Show one product's price in every currency in a single renderSwitch the current currency per entry and rebuild ranges for variable and grouped types
The optional price list under a product is built inside woocommerce_price_html(). For each currency the current currency is switched, the value is formatted with that currency's decimals and separators, and the original currency is restored afterwards.
A variable product's range cannot be computed as base min/max times rate, because individual variations may have fixed prices. _get_min_max_variation_prices() recomputes each variation's price for the target currency and drops zero values. Grouped products iterate their children and handle variable children the same way.
Let other code mark an amount as final or convert its own amountswoocs_skip_conversion filter, an internal block flag, public convert/back-convert filters
woocs_skip_conversion is checked first in the converter: an integration that already priced an item in the active currency returns true and the amount passes untouched. The request flag woocs_block_price_hook turns conversion off for a code section, used for example while webhook payloads are built.
For the opposite direction, woocs_convert_price and woocs_back_convert_price let third-party plugins convert their own base-currency amounts, and convert_from_to_currency() converts between any two store currencies through the base: value / rate_from * rate_to.
Rounding, decimals and number formats
shipped · 2016What it givesPrices show with the right decimals and separators for each currency, and amounts typed by staff or sent to the payment gateway stay correct.
- Problem
- Currencies differ in decimals and separators, and WooCommerce reads these from global options that are shared by display, parsing of typed amounts and tax math.
- Solution
Per-currency values are injected through option filters, and switched off in the contexts where WooCommerce parses input or rounds in the base currency.
- Stack
Decimals per currency through one global WooCommerce optionFilter pre_option_woocommerce_price_num_decimals with the active currency's decimals
WooCommerce reads the number of decimals from a single option. FOX answers that option with the current currency's setting, and the converter rounds with number_format() to the same precision, so the stored cart value and the displayed value agree.
JPY and TWD are hard-coded as currencies without cents, the admin can add more, and the woocs_precision_on_calc filter lets a site override precision per currency.
Base-currency amounts rounded to the visitor currency's decimalsReturn base decimals while a shipping rate is being added and for raw variation arrays
Shipping rates are calculated in the base currency and converted later in woocommerce_package_rates. If they were rounded to the visitor currency's decimals first (for example 0 for JPY), the base value would lose precision before conversion. woocs_fix_decimals() returns the base decimals when add_rate is on the call stack.
The same applies to variation price arrays: woocs_fix_variation_decimal() formats them with base decimals before the converter runs over them.
Per-currency separators corrupt amounts typed in the order editorDetect order screens and their AJAX calls and keep the store separators there
Decimal and thousand separators are filtered per currency (six schemes in get_decimal_sep() and get_thousand_sep()). In the wp-admin order editor, WooCommerce's input fields, their JS validation and wc_format_decimal() all assume the store separator, so a comma decimal separator there would change the saved amounts. The per-currency separators are therefore switched off in the order editor.
woocs_is_admin_order_edit_context() recognises order-editor AJAX actions by name, other AJAX calls by the referer (wc-orders page, shop_order post type, order post id), and the HPOS and legacy screens. The request part is cached in a static variable; the screen check is re-evaluated because get_current_screen() is empty early in the request. Refund amounts typed with a comma are normalised before WooCommerce validates them.
Gateway rejects fractional amounts in zero-decimal currenciesRewrite PayPal Standard amount and tax fields as integers for no-cents currencies
apply_conversion() runs on woocommerce_paypal_args and reformats every amount_* and tax_cart field with 0 decimals when the current currency has no cents. enable_custom_currency() extends WooCommerce's PayPal currency whitelist so the gateway stays available when the visitor pays in a non-base currency.
Tiny crypto rates printed in scientific notationFormat rates with 15 significant digits and trim trailing zeros
A rate such as coins per USD can be far below 0.0001, and PHP's float to string gives exponent notation that the admin form and the rate e-mail cannot use. rate_to_string() computes the number of decimals from log10 of the value, caps it at 20, and strips trailing zeros.
Per-visitor currency on cached sites
shipped · 2016What it givesEach visitor keeps the currency they picked, even on cached sites or shared networks, and the mini cart and shipping update when they switch.
- Problem
- The selected currency is per-visitor state, but full-page caches serve one HTML to everyone, and many hosts break PHP sessions or put many visitors behind one IP.
- Solution
Currency storage is pluggable across six backends. On cached sites prices are rendered as markers and redrawn by AJAX against a per-browser storage key.
- Stack
Hosts where PHP sessions do not survive between requestsOne storage interface over session, transient, own DB session, Memcached, Redis, cookie
WOOCS_STORAGE exposes set_val/get_val/is_isset and switches on the configured backend. Transient storage is keyed by a hash of the client IP, with CF-Connecting-IP preferred behind Cloudflare because Cloudflare overwrites any client-supplied value. Memcached and Redis are supported as well.
WOOCS_SESSION reuses the woocommerce_sessions table with an HMAC-signed cookie, reads through the object cache, and writes on shutdown only when data changed.
Visitors behind one shared IP see each other's currencyKey transient storage by a random per-browser id kept in localStorage
With transient storage and cache mode on, front.js creates a random id once (crypto.randomUUID when available) and sends it with every request. The server sanitizes it with sanitize_key() and hashes it into the transient key, so office NAT or mobile carrier users get separate buckets. The id is generated lowercase on purpose: sanitize_key() lowercases, and a mixed-case id would hash to a different bucket.
In this mode the transient lives 30 days instead of 1, because the key is stable per browser. If localStorage is blocked the code silently falls back to the IP key.
Blocks and Store API requests bypass jQuery and lose the keyWrap window.fetch at load time and add the key as a header on same-origin calls
jQuery requests get the key through an ajaxSend handler, but WooCommerce Blocks, the Store API and PayPal Payments use fetch() and start before DOM ready. The wrapper is installed at top level. The key goes in an X-WOOCS-SK header, not a query argument, because a per-visitor query argument would split the page cache per visitor. Cross-origin calls are left alone, since a custom header triggers a CORS preflight that can break payment SDKs.
On the PHP side, wp-json requests to cart, store and batch routes keep the plugin loaded so the block cart and checkout render in the visitor's currency.
Page cache serves prices rendered for another visitorWrap each price in a marker and redraw all markers in one AJAX request
woocommerce_price_html() wraps every price in a span with the product id and a unique redraw id. On a cached page front.js collects the markers and posts them to woocs_get_products_price_html; variation JSON in the add-to-cart form and custom price shortcodes are refreshed the same way. A preloader style can hide stale prices until the answer arrives.
The server returns an empty answer when the currency the cached page was rendered in already equals the visitor's stored one, so such a page view costs no rendering. It renders get_price_html() only for published, visible products.
Mini cart and shipping stay in the old currency after a switchAdd the currency to the WooCommerce cart hash and force a fragment refresh
WooCommerce stores cart fragments in sessionStorage keyed by the cart hash, and the hash depends only on cart contents. FOX filters woocommerce_cart_hash to md5(cart + currency), so a switch invalidates the stored fragments and shipping is recalculated.
When the currency arrives in the URL on a cached page, front.js first mirrors it into the browser-key bucket and only then triggers wc_fragment_refresh, otherwise the refresh would read an empty bucket and fall back to the base currency.
Currency codes arrive from URL, AJAX, REST and stale storageAccept only codes from the store list; hidden currencies only for order managers
set_currency() stores only enabled currencies, and the stored value is re-checked on every request, so a currency removed by the admin falls back to the base. get_woocommerce_currency() never returns an unknown code to WooCommerce, because WooCommerce would write it onto the order.
Currencies marked private cannot be selected through ?currency= or the AJAX switch. The woocs_in_order_currency request value accepts a private currency only for users with edit_shop_orders.
Cart, shipping, coupons and payment
shipped · 2015What it givesShipping costs, free-shipping minimums and coupons work correctly in the customer's currency, and checkout charges only in currencies the shop accepts.
- Problem
- Shipping costs, coupon amounts and thresholds are stored in the base currency but compared with cart totals that are already converted.
- Solution
Each amount is converted where WooCommerce consumes it, and every threshold check is done in one currency by back-converting the cart side.
- Stack
Shipping costs and shipping taxes in the selected currencyConvert each rate's cost and tax array in package_rates, or use a fixed cost per currency
woocommerce_package_rates() multiplies cost and each tax entry by the rate and rounds to the currency's decimals. A fixed flat-rate cost per currency is stored inside the shipping method's own instance settings option. When a fixed cost is used, scaling the old tax would be wrong, so the tax is recalculated with WC_Tax::calc_shipping_tax().
Flat-rate formulas use a cart cost that is already convertedDivide the [cost] argument by the rate, because the resulting rate is converted later
A flat-rate cost such as '[cost] * 0.1' receives the cart cost in the visitor's currency, and the resulting shipping rate is then converted again in woocommerce_package_rates. woocommerce_fix_shipping_calc() divides the cost argument by the rate first, so the conversion happens once.
Free-shipping minimum compared across two currenciesRe-implement the availability check and back-convert subtotal and discounts to base
The free shipping method compares the displayed subtotal with min_amount. When the customer pays in the selected currency, the cart subtotal is in that currency, so the check is re-implemented: the subtotal is back-converted with the current rate, discount and discount tax are back-converted too, the ignore_discounts and tax display settings are respected, and the coupon/either/both requirements are evaluated as in WooCommerce.
If a fixed minimum per currency is set, it is back-converted too, so both sides of the comparison are in the base currency.
Fixed-amount coupons and spend limits stored in base currencyConvert amount and min/max spend when the coupon object loads, skipping percent coupons
woocommerce_coupon_loaded() converts the coupon amount (not for percent and percent_product types) and the minimum and maximum spend, then writes them back with the coupon setters, so all WooCommerce validation runs in one currency. Per-currency fixed coupon amounts and limits, stored as coupon meta, override the converted values.
Checkout must charge in a currency the shop acceptsForce base currency at each checkout entry in display-only mode; filter gateways
In display-only mode the currency is reset to base on the checkout page, on checkout_process, on the block checkout and on order review updates. In pay-in-selected mode the checkout request reads the stored currency explicitly, and optionally the currency is forced by the visitor's country. A pending order paid later from its pay link is switched to the order's own currency.
Payment gateways can be hidden or shown per currency, since many gateways support only some currencies.
Exchange rates: sources, schedule, guards
in the productWhat it givesExchange rates update on schedule from many sources. A failed or absurd rate is rejected instead of repricing the whole store, and a margin can be added.
- Problem
- Rates come from many APIs with different bases and formats, must refresh on schedule on sites where WP-Cron is unreliable, and one bad response must not reprice the store.
- Solution
One provider class per source normalises to 'units per 1 base', an option-backed scheduler runs on normal requests, a failed fetch never writes zero or an error text as a rate, and new rates can be clamped to per-currency limits.
- Stack
Fifteen rate sources with different bases and payload formatsAbstract RateProvider with URL and parser per source, loaded by class naming convention
RateProvider::getRate() builds the URL, fetches with a timeout and delegates to parseResponse(). Central-bank feeds are quoted against their own currency, so the parsers compute cross rates: ECB is EUR based, and the Bank of Russia feed also carries a Nominal (rate per 10 or 100 units) that has to be divided out.
get_rate_provider() maps the configured source name to a class file. Sites can add their own sources through the woocs_add_custom_rate and woocs_add_aggregator_processor filters.
Crypto price API treats any ticker, including CAD, as a coinRefuse fiat codes before the request and invert USD-per-coin into coins-per-USD
The Pulse Verity source returns a USD price for any symbol, so a fiat code would silently get the price of an unrelated token. The provider checks a hard-coded ISO 4217 list first, because crypto payment plugins add coins to the WooCommerce currency list and that list cannot be trusted. It works only with a USD base, sends the API key as a Bearer header, reports the API's own error code for any non-2xx answer (wrong key, plan restriction, rate limit) and rejects zero or non-numeric prices. The returned USD price per coin is inverted into coins per 1 USD.
Rate updates on sites where WP-Cron is unreliable or traffic is constantOwn scheduler in one option, checked on normal requests; attaching never moves a pending run
PN_WP_CRON_WOOCS keeps start, next, interval and last run per hook in one option and is checked from init, admin_init and rest_api_init. Because it is called on every request, attach() must be idempotent: with an unchanged interval it leaves next untouched, and on an interval change it recomputes next from the last run, capped at now plus one interval. The three hooks are registered with 0 accepted arguments, because rest_api_init passes the WP_REST_Server object, which would otherwise land in the $reset parameter and restart the schedule on every REST request.
process() moves the next run forward before calling the hook, catches and logs exceptions thrown by it, and runs each hook at most once per request. The updated schedule is saved to the option after the hooks have run.
One failed or absurd API response must not reprice the whole storeReplace a rate only with a positive number and clamp new rates to per-currency floor and ceiling
rate_auto_update() fetches every currency in its own try block and replaces a rate only with a positive number, so a failing currency or source does not stop the others and never writes zero or an error text as a rate. get_rate() passes every numeric rate through ExchangeRateLimiter, which clamps it to an optional floor and ceiling per currency. After saving, the woocs_sheduler_rates_updated action lets other code react.
Changing the base currency invalidates every stored rateMark the new base and refetch all rates against it in the same request
All rates are stored as units per 1 base, so a new base makes every number wrong at once. save_etalon() saves the new base flag, switches default_currency in memory and calls get_rate() for every other currency before answering the admin screen, so the form is refilled with consistent values.
Shops add a margin on top of market ratesApply a fixed or percent markup per currency in one filter over the currency list
The markup is stored next to the rate as a fixed amount or a percentage and applied in a woocs_currency_data_manipulation filter at priority 1, so every consumer of get_currencies() sees the marked-up rate. get_currencies(true) skips filters when the raw value is needed.
Orders, emails and reports after the sale
in the productWhat it givesEach order keeps the currency and rate it was paid at, so emails, invoices, order edits and sales reports show the right amounts.
- Problem
- An order is stored in the currency it was paid in, while admin screens, emails, invoices, analytics and later edits run with some other current currency.
- Solution
The checkout rate is saved on the order, the order's currency is forced wherever the order is rendered, and analytics rows are converted back to base.
- Stack
Know later at what rate an order was paidStore rate and base currency as order meta through the CRUD API at checkout
At checkout and on the thank-you page the rate, base currency and a 'changed manually' marker are written with $order->update_meta_data(), which works for both HPOS and post storage. If the current currency is not in the list, the base rate is used. The meta keys are hidden from item display. An admin can correct the saved rate from the order screen, and the order's product analytics rows are re-synced.
Move a stored order to another currencyBack-convert every amount with the saved rate, then apply the target currency's rate
Recalculation converts order totals, line subtotals and totals, per-rate tax arrays, shipping items and their taxes, tax items and refunds. On HPOS it goes through the CRUD setters, also converts fee lines and coupon lines, and recalculates taxes. On legacy storage it updates post meta and order item meta directly, including the order's cart discount.
If a site defines woocs_order_recalculated_custom(), it is called before the save with the order, both currencies and the old rate, for amounts that must keep a fixed value per currency. A bulk action recalculates all orders to the base currency.
Emails, invoices and account pages render in the visitor's currencySwitch to the order's currency right before each order view or email is rendered
The current currency belongs to whoever triggered the request: an admin completing an order, a cron job, or a customer browsing in another currency. FOX switches to the order's currency in wc_get_template for email templates, on the completed notification and on email resend, for PDF invoice rendering, on My Account order lists and order views, in the admin order preview, and per row of the HPOS order list through the buyer name filter.
WooCommerce Analytics sums orders paid in different currenciesAfter WooCommerce writes lookup rows, divide them by the order's saved rate
WooCommerce Analytics copies order amounts into its own lookup tables as they are, so revenue would mix currencies. WOOCS_analytics hooks woocommerce_analytics_update_product, _tax and _coupon, reads the row WooCommerce just wrote, divides each amount by the order's saved rate (or the current rate if none) and rounds to base decimals. The legacy WooCommerce reports are adapted the same way by back-converting each order.
Editing an order in wp-admin uses the admin's currencyRun order-editor AJAX in the order's currency and apply fixed prices to added items
wp-admin normally runs in the base currency. For the order editor's AJAX actions (add item, save items, recalculate taxes) the current currency is set to the order's, for users who can edit orders. When a product with a fixed price in that currency is added manually, the item subtotal and total are set from the fixed price and the order totals are recalculated. When a coupon is added in the editor, the current currency is switched to the order's.
Contexts that must stay in base currency
in the productWhat it givesSearch engines, exports, syncs and admin screens see the shop's base prices, so stored data and connected services stay consistent.
- Problem
- Machine consumers read prices through the same hooks as shoppers. A sync, a webhook or a crawler may carry a visitor's session and receive converted numbers.
- Solution
Requests are classified before the plugin loads, and the base currency is forced around specific WooCommerce operations.
- Stack
The WooCommerce REST API must answer in the base currency on any web server (premium)Match /wc/vN/ routes from REQUEST_URI or rest_route and do not load the plugin
POS and ERP systems sync prices through the WooCommerce REST API, and a request can carry the session of a visitor who selected another currency. The SCRIPT_URI server variable used by the general REST guard exists on Apache but not on nginx, so the route is also parsed from REQUEST_URI using rest_get_url_prefix(), or from ?rest_route=, and the plugin returns before its object is created for any wc/vN route. The Store API and FOX's own namespace are not affected.
woocs_validate_currency() answers with the store currency when the plugin object does not exist in the request. Premium edition.
Webhook payloads must be built in the base currency (premium)Switch to base only while the payload is built, for async and shutdown-time sync delivery
Async delivery runs through Action Scheduler: base currency is switched on at priority 1 of woocommerce_deliver_webhook_async and off at 999. Synchronous delivery is harder: since WooCommerce 4.4 webhooks are queued and delivered on shutdown, so FOX turns base currency on inside the woocommerce_webhook_deliver_async filter only when it is called on shutdown with a WC_Webhook and async=false, and turns it off in woocommerce_webhook_payload, right after build_payload().
Delivery can run in a request that carries a visitor's cookies and therefore that visitor's currency. Both the conversion flag and current_currency are switched, because fixed per-currency prices read current_currency directly. Premium edition.
Search engines index converted prices and currenciesReset known bots to base and rewrite schema.org offers back to base currency
Crawlers can arrive with a currency in the URL. Known bot user agents are reset to the base currency in wp_head unless the admin disables it. Independently, structured_data_product_offer() sets priceCurrency to the base and divides price, lowPrice, highPrice and priceSpecification entries by the rate, so product markup stays consistent with the store currency.
Exports, admin product screens and bulk editors must see stored base valuesReset to base for exports and admin variation loading; skip loading for some AJAX
Product CSV export resets the currency before its query, so exported prices are base values that can be imported back. Loading variations in the product editor and every non-AJAX wp-admin page run in the base currency. The plugin does not load for the BEAR bulk editor's AJAX actions, for order item loading, for REST requests whose URL contains /refunds or paypal (PayPal Payments), and for backend refunds unless a known plugin needs it, because those requests write stored amounts.
Headless front ends need prices in a given currency per requestREST endpoints that switch currency for one call and restore it afterwards
woocs/v3 exposes the currency list, one product's price and a batch of products' prices. The callback sets the requested currency only if it exists in the store, computes prices with the same precedence as the main converter (fixed, country, role, then rate), returns raw values per variation with min and max or HTML, and restores the previous currency.
An Etsy order importer saved AUD orders with the currency GeoIP had picked for the requestPatch the importer to reset FOX to the shop's base currency before it writes an order
The shop's base currency was AUD. During the import request GeoIP had switched the current currency to USD, and the importer wrote the current currency onto each order. A call to $WOOCS->reset_currency(), guarded by class_exists('WOOCS'), was added in two places in the importer's order code, so orders are written in the base currency. This is a patch to the third-party importer and has to be reapplied after that plugin updates. The customer confirmed it worked.
Numbers
wordpress.org- Active installs 50,000+ wordpress.org counter.
- Reviews 250 Average 4.4 out of 5.
- Years supported 12 Since 20 September 2014, last update 12 October 2026.