DEV Community

OPTTOYSCHINA
OPTTOYSCHINA

Posted on

Stop using a generic FX API for prices. Use the central banks.

Our catalogue is priced in one currency — Chinese yuan — and read by buyers in dozens of countries. Somewhere between the database and the buyer's screen, ¥9.30 has to become a number they recognise, and it has to be the same number on every reload.

That turns out to be three separate problems, and the usual answer ("call an FX API") solves none of them well. Here is what we ended up with, including the two bugs that cost us the most.

Problem one: which rate is the real rate

A generic FX API gives you a mid-market rate aggregated from somewhere. For a price a buyer will act on, that is the wrong number, for a boring reason: your buyer's accountant does not use it. They use whatever their central bank published this morning, because that is what the invoice will be checked against.

So we take each currency from the institution that actually sets the reference:

Currency Source
CNY → USD People's Bank of China, central parity (ccpr.json)
RUB Central Bank of Russia
KZT National Bank of Kazakhstan
UZS Central Bank of Uzbekistan
AED USD peg, 3.6725 — it is a peg, not a market

The AED line is the interesting one. There is no point calling an API for a currency that has been pegged at 3.6725 to the dollar since 1997. We derive it:

// Дирхам привязан к доллару фиксированно. Тянуть его из «курсовой» ленты —
// значит каждый день заново узнавать одно и то же число, и однажды получить шум вместо него.
const AED_ZA_USD = 3.6725;
rates.AED = rates.USD * AED_ZA_USD;
Enter fullscreen mode Exit fullscreen mode

One source of truth, no drift between two feeds that should agree.

Everything else falls through to USD. That was a deliberate narrowing: we used to localise currency for far more countries, and every extra currency was another surface where the price could be wrong. A buyer in Chile reading a dollar price is not confused. A buyer in Chile reading a wrong peso price is gone.

Problem two: the rate is not the price

A published price is not the raw rate. Ours is two multipliers on top, and keeping them separate matters:

// два множителя, и они НЕ взаимозаменяемы:
//   product_pct — наша наценка на товар, экономика
//   currency_pct — запас на курс: комиссии банков и движение курса за сутки
const opublikovannyi = syroi * (1 + currency_pct / 100);
Enter fullscreen mode Exit fullscreen mode

Today's live numbers from our own feed, straight out of the endpoint:

Currency Raw Published Ratio
USD per CNY 0.14810207 0.155507 ×1.05
KZT 66.68 70.014 ×1.05
RUB 12.567 13.19535 ×1.05
UZS 1767.71 1856.0955 ×1.05
AED 0.54390486 0.5711 ×1.05

The 15% product markup lives in the product price, not in the rate. Two reasons, both learned the hard way: the markup changes for commercial reasons and the buffer changes for financial ones, and if they share a variable you can no longer answer "why did this price move?" — which is the only question anyone asks about prices.

The rates refresh once a day, 06:30 UTC, on a timer. Not per request. A price that changes while a buyer is reading it is a price they will not trust, and a central bank publishes once a day anyway.

Problem three: the flicker

This is where we shipped the actual bug, and it is worth describing because it is invisible in code review and obvious to a customer.

Country detection is asynchronous — we read it from the edge:

window.OTC_COUNTRY_READY = fetch('/cdn-cgi/trace').then(razobrat);
Enter fullscreen mode Exit fullscreen mode

Which means at first paint you do not know the country. The naive flow is: render in a default currency, then swap when detection resolves. So a buyer in Dubai loaded the page, saw tenge, and watched it turn into dirhams a beat later. From their side that is not a loading state. That is a site that just showed them someone else's price.

Two fixes, and both were needed:

1. The default has to be the global one, not the home one. Our fallback was the domestic currency because the domestic market came first historically. Wrong default: a wrong dollar price is a rounding argument, a wrong tenge price is a different country.

const VALYUTA_PO_UMOLCHANIYU = 'USD';
Enter fullscreen mode Exit fullscreen mode

2. Convert before paint, not after. Ship the raw yuan value in the markup and let a blocking inline script do the arithmetic before anything is displayed:

<span class="cena" data-rmb="9.30"></span>
<script is:inline>
  /* до первой отрисовки: ничего не мигает, потому что нечему меняться */
  var k = window.__KURS__ || 0.155507;
  document.querySelectorAll('.cena[data-rmb]').forEach(function (e) {
    e.textContent = (Number(e.dataset.rmb) * k).toFixed(2) + ' $';
  });
</script>
Enter fullscreen mode Exit fullscreen mode

An Astro-specific trap that cost us a day here: <script is:inline>{ … }</script> — with the body wrapped in braces — is not executed. Astro treats it as an expression and emits the code as text. It has to be a plain <script is:inline> with the JavaScript directly inside. The page looks fine, the script is in the HTML, and nothing runs.

The rules we kept

  • Take the rate from the institution your buyer's accountant will quote, not from an aggregator.
  • A pegged currency is arithmetic, not a feed.
  • Keep the commercial markup and the FX buffer in separate variables, even when they are both "just a multiplier".
  • Refresh on a schedule, not per request. Daily source, daily cadence.
  • The default currency is the one most of the world reads, because the default is what a buyer sees when detection is slow — and slow detection is the normal case on a cold mobile connection.
  • Do the conversion before first paint. A flicker between two currencies is read as a broken price, not as a loading state.

Our catalogue is public if you want to see the result under load — 385,341 listings, prices recomputed daily, at opttoyschina.com. The delivery-side of the same problem, where the inputs include a factory calendar that goes to zero for six weeks a year, is written up here.

Top comments (0)