<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Elivia Dave</title>
    <description>The latest articles on DEV Community by Elivia Dave (@elivia_dave_1dafb4920f28d).</description>
    <link>https://dev.to/elivia_dave_1dafb4920f28d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071881%2Ff71f71d9-8151-4cd7-a06b-269182ce23ce.png</url>
      <title>DEV Community: Elivia Dave</title>
      <link>https://dev.to/elivia_dave_1dafb4920f28d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elivia_dave_1dafb4920f28d"/>
    <language>en</language>
    <item>
      <title>Stop converting your SaaS prices with live FX rates</title>
      <dc:creator>Elivia Dave</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:45:37 +0000</pubDate>
      <link>https://dev.to/elivia_dave_1dafb4920f28d/stop-converting-your-saas-prices-with-live-fx-rates-20a9</link>
      <guid>https://dev.to/elivia_dave_1dafb4920f28d/stop-converting-your-saas-prices-with-live-fx-rates-20a9</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It was wrong in three separate ways, and it took a while to see that they were three problems and not one.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem one: the price moved every day
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the part I'd underweighted. A price is a &lt;em&gt;promise&lt;/em&gt;, and a number derived from a third-party rate feed can't make promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem two: display and charge disagreed
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem three: we quoted prices we couldn't collect
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The lesson generalises past currency: &lt;strong&gt;display capability and transaction capability are different things, and your code should know which one it's answering.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we replaced it with
&lt;/h2&gt;

&lt;p&gt;An authored price book. Every supported currency gets its own hand-written price ladder rather than a conversion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MONTHLY_PRICE_LADDER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BillingCurrencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PriceLadder&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="mi"&gt;149&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;149&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;NGN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="nx"&gt;_900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;39&lt;/span&gt;&lt;span class="nx"&gt;_900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="nx"&gt;_900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;129&lt;/span&gt;&lt;span class="nx"&gt;_900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;149&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;239&lt;/span&gt;&lt;span class="nx"&gt;_900&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;GHS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;249&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;349&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;399&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_099&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;149&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_999&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;XOF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;79&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;149&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;89&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Behind it sits an &lt;em&gt;anchor rate&lt;/em&gt; per currency, but the framing matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CURRENCY_ANCHOR_RATE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BillingCurrencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;NGN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;GHS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;XOF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ZAR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;EUR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;GBP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.79&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are &lt;strong&gt;pricing decisions, not market data.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;stale and stable&lt;/em&gt;, not toward &lt;em&gt;fresh and volatile&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-decimal trap
&lt;/h2&gt;

&lt;p&gt;This one is worth the price of admission on its own.&lt;/p&gt;

&lt;p&gt;Payment providers take amounts in minor units — cents, pesewas, kobo. The near-universal idiom is &lt;code&gt;amount * 100&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The West African CFA franc has no minor unit.&lt;/strong&gt; 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 &lt;strong&gt;one hundred times&lt;/strong&gt; the intended price.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CURRENCY_MINOR_UNIT_EXPONENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BillingCurrencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;NGN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;GHS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;XOF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;KES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ZAR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;EUR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;GBP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;* 100&lt;/code&gt; hardcoded anywhere in your payment path, it is a latent 100x overcharge waiting for your first customer in one of those markets.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that isn't a bug
&lt;/h2&gt;

&lt;p&gt;Once prices are authored rather than derived, you can price a market &lt;em&gt;deliberately&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;do&lt;/em&gt;, which in hindsight is the actual argument for authored prices — the three bugs were just what forced the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're doing this
&lt;/h2&gt;

&lt;p&gt;Rough order I'd suggest:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://www.menuraq.com" rel="noopener noreferrer"&gt;Menuraq&lt;/a&gt;, 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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>payments</category>
      <category>webdev</category>
      <category>startup</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
