Home / Blog / Multi-currency WooCommerce: skip the plugin, write the code

Multi-currency WooCommerce: skip the plugin, write the code

Instead of paying $70 for a plugin and inheriting messy behaviour, I wrote a tight implementation for the requirement. What I built and how.

A client ran an English-language WooCommerce site for export. The brief: “show TRY, USD, EUR, charge in USD”. The market has plugins like WOOCS and CURCY, but each came with limits: usage caps, price params handled as static conversions, or a real performance hit. I solved it in code, and the result was clean. Here’s how.

Requirements

What we needed:
– User can pick TRY, USD, or EUR.
– Prices show in the chosen currency.
– Rates follow the central bank, updated daily.
– Checkout happens in USD (they have a Stripe USD account).
– Math is exact, no floating-point drift.

On every plugin I looked at, this combo had some mismatch. The rate updates automatically, but isn’t frozen at checkout, so the user pays at a different rate. Or prices are correct, but the stock system gets the wrong currency sent to it.

Design decisions

  1. Base currency is USD. WooCommerce’s native store currency is USD. All prices live in the database as USD.
  2. Exchange rate table updates daily, pulled from the central bank API via cron.
  3. User’s selected currency lives in a session cookie. A URL param can set it too (?currency=EUR).
  4. Display price is USD multiplied by the rate. When we want psychological pricing (like 9.99), it rounds to the nearest .99.
  5. Checkout is always USD. The user sees a clear notice: “Payment will be processed in USD”.
  6. Inventory lives in a single system, currency-independent.

Implementation pieces

Exchange rate table

I skipped wp_options and added a dedicated table, wp_ac_exchange_rates:

CREATE TABLE wp_ac_exchange_rates (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  base_currency CHAR(3) NOT NULL,
  target_currency CHAR(3) NOT NULL,
  rate DECIMAL(18,8) NOT NULL,
  fetched_at DATETIME NOT NULL,
  UNIQUE KEY uk_pair_date (base_currency, target_currency, fetched_at)
);

Cron-based updates

I used a real cron instead of WP-Cron, because WP-Cron is unreliable on low-traffic sites. Hostinger cron pulls from the central bank XML three times a day.

Price display via filter

add_filter('woocommerce_product_get_price', function ($price, $product) {
    $currency = ac_get_customer_currency();
    if ($currency === 'USD') return $price;
    $rate = ac_get_exchange_rate('USD', $currency);
    $converted = $price * $rate;
    return ac_psychological_round($converted);
}, 10, 2);

Similar filters were added for _sale_price, _regular_price, and woocommerce_get_price_html. Wiring each one up took a while, but keeping control made the result clean.

Freezing the rate at checkout

When the cart is built, the selected currency and the current rate get written to the order meta:

$order->update_meta_data('_ac_display_currency', $currency);
$order->update_meta_data('_ac_display_rate', $rate);
$order->update_meta_data('_ac_display_total', $displayed_total);

Later, when issuing invoices, both USD (the actual charge) and the displayed TRY/EUR (the customer reference) are on hand.

Psychological rounding

Simple:

function ac_psychological_round($amount) {
    $int = floor($amount);
    return $int + 0.99;
}

Instead of showing 32.40 TRY, we show 32.99. Small, but it’s a real sales-psychology difference.

Currency switcher

Three-flag dropdown in the header. The choice is written to a cookie, the page refreshes, prices update.

Clashing with the cache

LiteSpeed and page cache have to produce a different cache entry per currency, or you’ll show the wrong price. Two options: a URL param like ?currency=TRY as the cache key, or cookie-based cache variations. I picked the cookie route, the URL stayed cleaner.

The LiteSpeed rule:

Cache Vary: ac_currency

Now each currency gets its own cached copy.

Issues I ran into

  • Fixed-amount coupons were being interpreted as USD. To give “20 TRY off” I added extra logic, with currency stored in coupon metadata.
  • What currency do we send to analytics? Google Analytics wants a single currency, so we normalised to USD.
  • Refunds. If the refund request comes in TRY, we convert through the order’s _ac_display_rate and refund in USD.

Plugin vs custom, the decision

Paying $70 for a ready-made solution felt tempting at first. But design took two days, implementation one week. We thought through the fiddly cases up front, which the plugin would have exposed later. No license renewal, no plugin breaking-change anxiety.

Before reaching for a complex plugin, look at the real requirement. Sometimes 800 lines of code is a lighter and more legible solution than a 3,000-line plugin.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch