DEV Community

Elivia Dave
Elivia Dave

Posted on

Stop converting your SaaS prices with live FX rates

We priced our product in USD and converted to local currency at render time, using a live exchange rate. It seemed obviously correct. One price to maintain, every market gets a fair local number, the rate stays honest.

It was wrong in three separate ways, and it took a while to see that they were three problems and not one.

I work on Menuraq, a platform for food businesses, and most of our merchants are in West Africa — Ghana, Nigeria, the CFA franc zone. So the currency question isn't a nice-to-have edge case for us, it's most of the customer base. Here's what broke and what we replaced it with.

Problem one: the price moved every day

If you convert $29 at today's rate, the cedi price is a different number today than it was yesterday. Not dramatically — a few percent — but visibly. A merchant who looked at the pricing page on Monday and came back Thursday saw a different number and reasonably concluded something shady was happening.

Worse, it makes the price unquotable. A salesperson can't say "it's ₵399 a month" if the page might say ₵412 tomorrow. Nobody can put it in a deck. Nobody can tell a colleague. The price stopped being a fact about the product and became a live market readout, which is not what a price is for.

This is the part I'd underweighted. A price is a promise, and a number derived from a third-party rate feed can't make promises.

Problem two: display and charge disagreed

We charm-rounded the displayed price — nobody wants to see ₵412.37, so it rendered as ₵399 or similar. But the amount actually sent to the payment provider was computed from the raw conversion.

So the page said one number and the card was charged another. Small gap, entirely indefensible, and exactly the kind of thing that turns into a chargeback and a bad review. The rounding lived in the view layer and the charging lived in the service layer, and neither knew about the other.

If you have a "format this nicely for display" function anywhere near a price, go check right now whether the money that moves is derived from the same value you're showing. This is a very easy bug to ship and a very hard one to notice, because it only shows up in the gap between two systems that each look fine on their own.

Problem three: we quoted prices we couldn't collect

Currency detection was geography-based, so a merchant in The Gambia got quoted in Gambian dalasi. Our payment provider (Paystack) cannot settle GMD. Same for Sierra Leonean leone, Liberian dollar, Guinean franc, Cape Verdean escudo, Mauritanian ouguiya.

We were rendering a price in a currency we had no ability to charge. The checkout failed at the last step, after the merchant had already decided to buy.

The lesson generalises past currency: display capability and transaction capability are different things, and your code should know which one it's answering. We now resolve those separately — a currency can be displayable, settleable, both, or neither, and the unsettleable ones deliberately fall through to USD rather than quoting something we can't collect.

What we replaced it with

An authored price book. Every supported currency gets its own hand-written price ladder rather than a conversion.

const MONTHLY_PRICE_LADDER: Record<BillingCurrencyCode, PriceLadder> = {
  USD: { 19: 19,     25: 25,     29: 29,     79: 79,      149: 149 },
  NGN: { 19: 29_900, 25: 39_900, 29: 46_900, 79: 129_900, 149: 239_900 },
  GHS: { 19: 249,    25: 349,    29: 399,    79: 1_099,   149: 1_999 },
  XOF: { 19: 11_500, 25: 15_000, 29: 17_500, 79: 47_500,  149: 89_500 },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The keys are the USD price points. The values are what that tier actually costs in that market. There is no conversion at read time — a lookup returns an authored number.

Behind it sits an anchor rate per currency, but the framing matters:

export const CURRENCY_ANCHOR_RATE: Record<BillingCurrencyCode, number> = {
  USD: 1, NGN: 1600, GHS: 16, XOF: 600, KES: 130, ZAR: 18, EUR: 0.92, GBP: 0.79,
};
Enter fullscreen mode Exit fullscreen mode

These are pricing decisions, not market data. We hold them steady through normal FX movement and reprice deliberately when a currency has genuinely repriced. The anchor is what we use to derive a starting ladder and to handle price points nobody has authored yet — but it never tracks the market automatically.

That last bit matters for graceful degradation. If someone adds a new tier and forgets to extend the ladders, the lookup misses and falls back to anchor conversion. You get a slightly unpolished number instead of a crash or a live-FX number. Degrade toward stale and stable, not toward fresh and volatile.

The zero-decimal trap

This one is worth the price of admission on its own.

Payment providers take amounts in minor units — cents, pesewas, kobo. The near-universal idiom is amount * 100.

The West African CFA franc has no minor unit. XOF's exponent is 0. There are no centimes in circulation. If you multiply an XOF amount by 100 before sending it to your payment provider, you charge a merchant in Senegal or Côte d'Ivoire one hundred times the intended price.

const CURRENCY_MINOR_UNIT_EXPONENT: Record<BillingCurrencyCode, number> = {
  USD: 2, NGN: 2, GHS: 2, XOF: 0, KES: 2, ZAR: 2, EUR: 2, GBP: 2,
};
Enter fullscreen mode Exit fullscreen mode

XOF is not alone — JPY, KRW, VND, CLP, ISK and several others are also zero-decimal, and a few currencies use three decimal places (KWD, BHD, TND, JOD). If you have * 100 hardcoded anywhere in your payment path, it is a latent 100x overcharge waiting for your first customer in one of those markets.

Display has to match: XOF is formatted with zero fraction digits, because "17 500,00 F CFA" shows a precision the currency doesn't have.

The part that isn't a bug

Once prices are authored rather than derived, you can price a market deliberately. Our Ghana ladder sits below anchor parity on purpose — it works out to roughly 13.7 GHS/$ against a 16 anchor. That's not a rounding artifact, it's a decision about what the entry tier needs to cost for a single-location operator in that specific market to say yes.

You cannot express that with a conversion function. A conversion function has exactly one opinion, applied everywhere, and it is always the market's opinion rather than yours. The moment we stopped converting, market-specific pricing became a thing we could simply do, which in hindsight is the actual argument for authored prices — the three bugs were just what forced the issue.

If you're doing this

Rough order I'd suggest:

  1. Audit for * 100 in your payment path. Fix zero-decimal currencies first — it's the only one of these that overcharges people.
  2. Check display-vs-charge parity. Whatever number renders must be the number that moves.
  3. Separate "can I show this currency" from "can I settle this currency." Ask your payment provider what they actually settle; the list is shorter than their marketing suggests.
  4. Then replace conversion with authored ladders — it's the biggest change and the least urgent.

Adding a currency for us now means five things: an anchor rate, a ladder, region codes, a minor-unit exponent, and a check that the provider can genuinely settle it. That's more work per currency than calling a rate API. It's also the first version of this that hasn't produced a support ticket.


I build Menuraq, an all-in-one platform for food businesses — menu, kitchen, counter and customer working off the same data. Happy to go deeper on any of this in the comments.

Top comments (0)