The price that lied about its own currency

Title card: The price that lied about its own currency

A cruise listing page will happily show you a number and a symbol side by side, and you will never once stop to ask whether the two of them have met.

We had a currency switcher on a cruise site. Pick your currency from the dropdown, prices redraw, everyone’s happy. Except that if you picked one we didn’t have a conversion rate for, the page didn’t fall over, didn’t warn anyone, didn’t log anything. It just swapped the symbol and left the number alone.

So a cabin priced at 1,200 in the site’s base currency displayed as £1,200. Same digits. Different currency. Off by whatever the exchange rate happened to be that week.

How it looked from the outside

Nobody reported it as a currency bug, because from the browser it isn’t one. There’s no visual tell. The layout is fine, the formatting is fine, the number is a plausible price for a cruise cabin. You’d have to already know the base price to spot that it hadn’t moved.

It surfaced the way these ones usually do — someone had the same listing open in two currencies and noticed the number hadn’t budged. Which, when you think about the maths, is not a thing that happens.

The three lines that did it

The helper that decides which currency to render is old, small, and looked entirely reasonable:

function getCurrencySymbol() {
    $settings = get_option( 'theme_options' );
    $bc = $settings['base_currency'];

    if ( isset( $_COOKIE['__user_currency'] ) ) {
        $bc = $_COOKIE['__user_currency'];
    } else {
        setcookie( '__user_currency', $bc, 0, COOKIEPATH, COOKIE_DOMAIN );
    }

    // ... derive symbol from $bc, return it
}

Read that as an English sentence and it says: if the visitor has chosen a currency, use it. Which is what you want. It’s the whole point of a currency switcher.

The problem is that this function only ever gets asked about the label. Somewhere else entirely, a different bit of code decides whether the amount gets converted, and that one checks the rates table before it does anything. If there’s no rate for the target currency, it sensibly declines to invent one and hands back the base figure untouched.

Two functions. Both behaving correctly on their own terms. One of them will refuse to guess and the other one absolutely will.

Which leaves the obvious question: how does a currency you can’t convert to end up in the dropdown at all? Because the list of currencies the site offers and the list of currencies the CMS holds rates for are two different lists, maintained in two different places, by people who reasonably assumed they’d stay in step.

Whose bug is it

You can argue this a few ways and I went round the houses on it before committing.

Fixing the dropdown is the obvious move: only offer currencies you can convert to. It’s a one-liner and it makes the symptom go away. It also means the fix lives in the UI layer, so the next thing that calls getCurrencySymbol() — a shortcode, a REST route, whatever we write in six months — gets to fall into exactly the same hole again. The bad state is still reachable, we’ve just stopped advertising the door.

Erroring out is worse. Half a listing page rendered and a fatal where the price should be, because someone’s stale cookie names a currency we dropped a rate for last month. Cookies outlive deployments.

What I actually want is the weakest possible guarantee, enforced in the one place that can enforce it: the symbol and the amount always agree. If we can’t convert, we don’t relabel. Show the base currency. It’s not what the visitor asked for, but it is true, and true beats accommodating when there’s money on the page.

The fix

Add a convertibility check, and gate the cookie on it:

function currency_is_convertible( $to, $from ) {
    if ( $to === $from ) {
        return true;
    }

    static $rates = null;
    if ( null === $rates ) {
        switch_to_blog( 1 );
        $rates = get_option( 'currency_rates' );
        restore_current_blog();
        if ( ! is_array( $rates ) ) {
            $rates = array();
        }
    }

    if ( ! empty( $rates['currencies'] ) ) {
        return isset( $rates['currencies'][ $to ] )
            && isset( $rates['currencies'][ $from ] )
            && (float) $rates['currencies'][ $from ]['rate'] > 0;
    }

    return false;
}

And in the symbol helper:

$baseCode = explode( ':', $bc )[0];

if ( isset( $_COOKIE['__user_currency'] ) ) {
    $selected     = $_COOKIE['__user_currency'];
    $selectedCode = explode( ':', $selected )[0];

    // Only honour the selection if we can actually convert to it, otherwise
    // fall back to base so the label always matches the amount.
    if ( currency_is_convertible( $selectedCode, $baseCode ) ) {
        $bc = $selected;
    }
}

Three things worth pulling out of that.

switch_to_blog( 1 ) — the rates are network-wide, edited once on the CMS site and read by every child site. Fetching them per-site would mean maintaining the same table in a dozen places, which is its own future post about a different bug.

The static $rates cache is not premature optimisation. getCurrencySymbol() runs inside price loops — search results, listing pages, anywhere with more than one cabin on screen — and without it you’re doing a blog switch and an option read per price rendered. Cheap individually. Not cheap thirty times on a search results page.

And the check deliberately ignores the external exchange-rate API we can fall back to. That API is flaky enough that I don’t want convertibility to be a question with a different answer depending on whether a third party is up. Configured rates only. If the CMS doesn’t know it, we can’t do it.

The bit I’d actually like you to take away

There’s a pattern here that isn’t about currency at all.

Two functions, split along a line that made sense when someone drew it — one owns presentation, one owns the number. Each has a sensible policy for missing data. Presentation says “show what they asked for”. Calculation says “don’t guess”. Neither is wrong. Put them next to each other on a page and they produce a lie that neither one could produce alone.

The tell is a shared input with a validity condition that only one side checks. The cookie was the shared input. Convertibility was the condition. Only the maths side ever asked.

Worth a look at your own code for the same shape. Anywhere a unit, a locale, a timezone or a label is chosen independently of the value it’s attached to, you’ve probably got one.

Leave a Reply

Your email address will not be published. Required fields are marked *