Native WooCommerce supports a single currency, but geo-pricing — showing prices in the visitor’s local currency based on their IP country — is a first-class requirement for international stores. Implementing multi-currency without a premium plugin requires: a currency detection layer, an exchange-rate source (cached API or manual table), price conversion filters, and cart/checkout currency locking to prevent currency switching mid-transaction.
Problem: A WooCommerce store needs to display prices in multiple currencies with real-time exchange rates and geo-based automatic currency selection — native WooCommerce only supports a single currency.
Solution: Implement multi-currency by hooking into woocommerce_product_get_price to multiply the base price by the exchange rate for the active currency. Store exchange rates as a transient refreshed from a currency API every hour. Set the active currency based on GeoIP lookup with WC_Geolocation::geolocate_ip() and persist the selection in the session with WC()->session->set().
The code below detects the user's currency from their country (set via WooCommerce geolocation), fetches exchange rates from a free API with 6-hour caching, converts displayed prices and cart totals, and stores the currency in the session for checkout consistency.
'USD', 'CA' => 'CAD', 'AU' => 'AUD',
'GB' => 'GBP', 'DE' => 'EUR', 'FR' => 'EUR', 'ES' => 'EUR',
];
public static function init(): void {
add_filter( 'woocommerce_currency', [ self::class, 'get_currency' ] );
add_filter( 'woocommerce_product_get_price', [ self::class, 'convert_price' ], 20, 2 );
add_filter( 'woocommerce_cart_subtotal', [ self::class, 'display_symbol' ], 20, 3 );
add_action( 'woocommerce_checkout_update_order_review', [ self::class, 'lock_currency' ] );
}
public static function get_currency(): string {
// Once checkout starts, lock to the session currency
if ( WC()->session && WC()->session->get( 'currency_locked' ) ) {
return WC()->session->get( 'customer_currency', get_woocommerce_currency() );
}
$country = WC()->customer ? WC()->customer->get_billing_country() : '';
$currency = self::COUNTRY_CURRENCY[ $country ] ?? get_woocommerce_currency();
return in_array( $currency, self::SUPPORTED, true ) ? $currency : get_woocommerce_currency();
}
public static function convert_price( string $price, WC_Product $product ): string {
if ( $price === '' ) {
return $price;
}
$currency = self::get_currency();
$base = get_woocommerce_currency();
if ( $currency === $base ) {
return $price;
}
$rate = self::get_rate( $base, $currency );
return (string) round( (float) $price * $rate, wc_get_price_decimals() );
}
private static function get_rate( string $from, string $to ): float {
$cache_key = "fx_rates_{$from}";
if ( ! self::$rates ) {
$rates = get_transient( $cache_key );
if ( false === $rates ) {
$response = wp_remote_get( "https://api.frankfurter.app/latest?from={$from}" );
if ( ! is_wp_error( $response ) ) {
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$rates = $data['rates'] ?? [];
set_transient( $cache_key, $rates, 6 * HOUR_IN_SECONDS );
}
}
self::$rates = is_array( $rates ) ? $rates : [];
}
return (float) ( self::$rates[ $to ] ?? 1.0 );
}
public static function lock_currency(): void {
if ( WC()->session ) {
WC()->session->set( 'customer_currency', self::get_currency() );
WC()->session->set( 'currency_locked', true );
}
}
}
add_action( 'woocommerce_loaded', [ 'My_Multi_Currency', 'init' ] );
NOTE: Order totals stored in wp_woocommerce_order_items use the currency active at checkout — always store both the converted amount and the original base-currency amount as order meta so that refund calculations, reporting, and accounting integrations can work with consistent base values; use update_post_meta( $order_id, '_base_currency_total', $base_total ) at checkout.