WooCommerce Multi-Currency: Custom Exchange Rates and Geo-Pricing

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.