Language

Get in touch

WordPress plugin · since 2016

WPCS

A currency switcher for WordPress sites without WooCommerce: prices written in content are converted to the visitor's currency.

First release
Active installs
900+
Rating
4.1 / 5 (13)Read reviews
  • PHP
  • WordPress shortcodes
  • Transients
  • Cookies / PHP sessions
  • WP-Cron
  • wp_remote_get
  • SimpleXML / SoapClient
  • MaxMind GeoIP (legacy .dat) and GeoIP2 (.mmdb)
  • PharData
  • admin-ajax
  • jQuery
View on wordpress.org wordpress.org

About

WPCS brings the currency switching of WOOCS to ordinary WordPress sites: tour operators, real estate listings, service price lists. Prices are written in one base currency through the [wpcs_price] shortcode or a post meta field and shown in the currency the visitor picked or the one assigned to their country.

Without WooCommerce the plugin has none of the usual infrastructure: no price filters, no session handler, no geolocation service. It has to keep the visitor's choice on its own, fetch exchange rates from external sources that change or disappear, and keep working behind full-page caches that serve the same HTML to everyone.

The answer is a single price pipeline (rate, precision, separators, symbol template), pluggable per-visitor storage, rate providers that return a rate or an error instead of a zero, and a cache mode in which prices are emitted as tagged spans and re-rendered for each visitor through one AJAX call.

The premium build and the free build on wordpress.org share one code base; the free build is limited to two currencies.

WPCS · currency switcher

what was solved

LIVEsince 2016

Price conversion and formatting

shipped · 2016

What it givesVisitors see prices in their currency with the right symbol and decimals. Owners can add a margin to rates or set fixed prices per currency.

Problem
Prices in posts are written in one base currency. Each visitor must see them converted with the right rate, precision, separators and symbol position, without WooCommerce price functions.
Solution

All prices go through one pipeline: multiply by the rate, format with per-currency precision and separators, then wrap in a symbol template and a tagged span that keeps the base amount.

Stack
  • PHP
  • Shortcodes
  • number_format
Tasks · 6
Content holds prices in the base currency only[wpcs_price] converts a literal value or a post meta field by the current rate

The shortcode takes value, or meta_value with an optional post_id; post_id without meta_value reads the meta key wpcs_price. price() multiplies by the rate only when the current currency differs from the base, then passes the number to format_price_numeric() and the wpcs_price filter.

price_html() wraps the result in a span with a unique id and data-amount holding the base value, which the page cache mode later uses to re-render it.

Some prices must not follow the exchange ratetype="fixed" takes per-currency amounts like 'USD:15,EUR:20'

For fixed prices the shortcode parses the list into a map and shows the amount for the current currency, or 'none' if it is missing. The price-in-all-currencies list uses the same map instead of converting.

Currencies differ in cents, decimals and separatorsPrecision from a no-cents list and per-currency decimals; separators per currency

JPY and TWD are always shown without cents; any currency can be added to that list with hide_cents. Settings include per-currency decimals and per-currency decimal and thousands separators. The separator choice is an index into two parallel arrays, where index 0 means the global setting, so existing sites keep their formatting.

Sites want a margin on top of the market ratePer-currency 'interest' added to the rate on read; settings page sees raw rate

The interest is a fixed amount stored next to the rate. get_currencies() adds it to the rate everywhere except on the plugin settings page, so the admin edits the clean rate while the front end converts with the adjusted one. A wpcs_currency_data_manipulation filter runs after that for custom rules.

Show one price in all currencies, and convert between any twoFormatted price parsed back to base, then converted forward; cross rate to/from

The price info list strips the thousands separator from the already formatted string, normalizes the decimal separator, converts back to the base with back_convert() (1/rate) and then forward into every other currency. The [wpcs_converter] endpoint uses rate[to] / rate[from], both relative to the base, and drops cents for no-cents currencies.

A price must sometimes show in one currency for all visitorsfix_currency swaps the current currency for one converted price, then restores it

For a converted price the shortcode remembers the visitor's currency, switches to the requested one if it is configured, renders, and switches back so the rest of the page keeps the visitor's choice.

Exchange rate sources and updates

shipped · 2016

What it givesExchange rates update on their own from several sources, and a failed update never turns a price into zero.

Problem
Free rate APIs change, close or start requiring keys; national banks publish rates only against their own currency; and a failed fetch must not replace a working rate with zero.
Solution

Each source is a provider that returns a rate relative to the site base currency or -1 with an error message; WP-Cron refreshes rates and saves only values above zero.

Stack
  • PHP
  • wp_remote_get
  • SimpleXML
  • SoapClient
  • WP-Cron
Tasks · 6
Rate APIs appear, change and shut downProviders resolved by name; unknown names go to a filter for custom sources

The changelog tracks the churn: Yahoo removed in 1.1.1 (2017), Google removed in 1.1.2 (2018), both added back in 1.1.3 (2018), sources that stopped working removed again in 1.1.3 and 1.1.4, custom aggregators through a hook in 1.1.5 (2020). Each source is a class with getApiUrl() and parseResponse() on a shared RateProvider base.

get_rate_provider() maps the option value to a class name; if no class exists, wpcs_add_aggregator_processor lets a site plug in its own source, and wpcs_announce_aggregator adds it to the settings list. The settings list has 14 sources.

Central banks quote every currency against their ownCross rates computed as rate[to] / rate[base] when the site base differs

ECB publishes against EUR, the National Bank of Ukraine against UAH, Magyar Nemzeti Bank against HUF. Each provider handles three cases: base is the bank currency, target is the bank currency, neither. The MNB source is a SOAP service with comma decimals and per-unit quotes (for example 100 JPY), normalized before division.

A failed fetch must not set a rate of zeroProviders return -1 with an error; cron saves only rates greater than zero

get_rate() returns the provider error text instead of a number when the rate is negative. rate_auto_update() casts results to float and replaces a rate only when the new value is above zero, so a broken source never writes zero or an error text as a rate.

Rates must refresh without an admin opening the settingsWP-Cron event with added 'week' and 'month' schedules

The settings offer hourly, twice daily, daily, weekly or monthly. The plugin registers the two extra intervals through cron_schedules and schedules the event with the selected interval on load when it is not scheduled yet.

Changing the base currency invalidates all stored ratessave_etalon() marks the new base and refetches every rate against it

The AJAX handler saves the new is_etalon flag, sets the new default currency in memory, requests a rate for every other currency through the active provider and returns the rates to the settings form.

Setting up many currencies by hand is slow and error-proneBuilt-in table of 118 currencies; bulk add fetches rate and flag (premium UI)

WPCS_World_Currencies holds symbol, description, decimals and flag code per currency. add_currencies_ajax() adds the selected ones, fetches a rate for each, registers unknown symbols as custom signs and downloads the flag into the media library, falling back to a remote flag URL if the download fails. The bulk-add dialog is shown only in the premium build.

Visitor currency and page caching

shipped · 2016

What it givesEach visitor keeps their chosen currency even on cached pages, so the site can stay fast without showing someone else's currency.

Problem
The chosen currency belongs to one visitor, but full-page caches serve the same HTML to everyone, and storage keyed by IP mixes visitors behind one proxy or NAT.
Solution

Pluggable per-visitor storage, and a cache mode in which the browser asks admin-ajax to re-render all price spans of a cached page for the visitor's currency.

Stack
  • Transients
  • Cookies
  • PHP sessions
  • admin-ajax
  • jQuery
Tasks · 5
Cached HTML shows the currency of whoever warmed the cacheCache mode: JS collects all .wpcs_price spans and re-renders them in one request

Each price span carries a unique id and the base amount. With the cache option on, front.js sends the id/amount pairs to wpcs_get_prices_html, which resolves the visitor's currency server-side and returns a map of id to new HTML plus the current currency; the switcher widgets are then synced to it. The mode is present in the 2016 code.

While the switcher widgets are set to the returned currency, a flag stops their change events from starting another currency switch.

Hosts differ in what per-visitor storage worksStorage strategy chosen in settings: session, IP-keyed transient or cookie

WPCS_STORAGE builds the backend by name from an abstract Storage class. SessionStorage::create() returns false when session_start() fails, and the wrapper then falls back to transient storage. Every value is sanitized on write.

Visitors behind one IP must not share one chosen currencyCookie storage: random UUID in a cookie, values in transients

The changelog names the reason: IP-keyed transients mix users who share an address. CookieStorage keys transients by a UUID cookie. If headers are already sent when the ID is created, it prints a footer script that posts the ID to a nonce-checked AJAX endpoint, which sets the cookie from a clean response.

?currency= in links splits caches and leaks into shared URLsOptional mode: set currency over AJAX and reload, no GET parameter

In the default mode wpcs_redirect() rebuilds the current URL keeping other GET parameters and the #anchor. In 'No GET data in link' mode it posts the currency to wpcs_set_currency_ajax and reloads. GET parameters echoed into the page script are sanitized recursively first.

Welcome or geo currency must apply once, not override a choiceFirst-visit flag in storage; default currency logic runs only while it is 0

On the first request the welcome currency is stored and the flag set; geo rules also run only for a first unique visit. If a stored currency is later removed from the list, get_currencies() falls back to the base.

Geo-IP currency selection

shipped · 2017

What it givesNew visitors see their country's currency automatically, based on rules the owner sets, without paying for a location service.

Problem
Without WooCommerce there is no geolocation service, so the plugin must map a visitor IP to a country on any host, behind proxies and CDNs, without a paid API.
Solution

A bundled MaxMind country database behind a resolver interface, with an optional GeoIP2 database downloaded by the site owner, and admin rules that map countries to currencies.

Stack
  • MaxMind GeoIP legacy
  • GeoIP2 / GeoLite2
  • PharData
  • Composer
Tasks · 4
Country detection on a site without WooCommerceBundled MaxMind GeoIP.dat with its PHP reader

WooCommerce's WC_Geolocation is not available on a plain WordPress site, so the plugin ships its own country database. lib/GeoIP.dat and geoip.inc are wrapped in OpenIpCountryResolver with a per-request cache by IP.

The real client IP is hidden behind proxies and CDNsRead X-Real-IP, then the first X-Forwarded-For hop, then REMOTE_ADDR

With the bundled database, a Cloudflare HTTP_CF_IPCOUNTRY header is used directly and the database lookup is skipped. A request whose REMOTE_ADDR is 127.0.0.1 or ::1 is resolved as 8.8.8.8 before the proxy headers are read.

Legacy country data is less accurateGeoIP2 option: GeoLite2 .mmdb downloaded and unpacked in admin

With a MaxMind license key the admin downloads the GeoLite2-Country archive; the handler unpacks .tar.gz with PharData, finds the .mmdb file, copies it to uploads/currency-switcher and removes temporary files on success and on error. Without a key the plugin stays on the bundled database. Lookups go through the geoip2 Composer package.

Map countries to currencies, with a code-level overrideRules currency => countries; wpcs_geo_position filter overrides the country

init_geo_currency() resolves the country, passes it through wpcs_geo_position, stores it for the visitor and applies the first matching rule on a first visit only. [wpcs_check_country] prints the detected country for debugging a rule.

Hardening public input

shipped · 2021

What it givesAnonymous visitors cannot misuse the currency switcher to inject content or change settings, so prices and pages stay as the owner set them.

Problem
The switcher runs on every front-end page, exposes AJAX endpoints to anonymous visitors and takes shortcode attributes from authors, so outside input reaches HTML, storage and settings.
Solution

Check currency codes from the URL and the switcher form against the configured list, cap and pattern-check request arrays, escape attributes, and encode JSON for an HTML context.

Stack
  • PHP
  • esc_attr
  • sanitize_text_field
  • Nonces
Tasks · 5
The public price endpoint takes an array from anonymous visitorsMax 500 items, id pattern check, scalar checks, JSON_HEX_* output

wpcs_get_prices_html is available to anonymous visitors. It slices input to 500 entries, accepts only ids matching the uniqid() charset used by price_html(), skips non-scalar prices, and encodes the response with JSON_HEX_TAG, AMP, APOS and QUOT because admin-ajax serves it as text/html.

Shortcode values end up in HTML data attributesdata-* attributes built with esc_attr() on key and value

price_html() writes values that come from [wpcs_price] attributes, such as the base amount or the raw 'USD:15,EUR:20' list of a fixed price, into data-* attributes of the price span. Keys and values are escaped and quoted, so an attribute value cannot inject markup.

The rates endpoint builds a shortcode string from request inputExclude list intersected with known codes, currency validated, precision intval

wpcs_rates_current_currency passes visitor input into do_shortcode(). Only currency codes that exist in the configuration survive, the result is esc_attr'd, and precision is cast to int, so no extra shortcode syntax can be injected.

A currency from the URL or the switcher form can be any stringURL and form values accepted only if they are configured codes; stored values sanitized

The currency-switcher form field and the ?currency= parameter are checked with array_key_exists() against the configured list before they reach storage, and WPCS_STORAGE::set_val() sanitizes every value.

HTML-entity symbols broke on settings form round-tripsSymbols posted as md5 hashes and mapped back to the known symbol list

Symbols such as руб. are stored as entities. The select posts md5(symbol), and the save handler maps the hash back to the original entity from the known list, so the entity is neither double-encoded nor taken from raw input. The save is protected by check_admin_referer(), and position is checked against a fixed list.

Numbers

wordpress.org
  • Active installs 900+ wordpress.org counter.
  • Reviews 13 Average 4.1 out of 5.
  • Years supported 10 Since 26 May 2016, last update 21 September 2026.