Multi-Currency Storefronts
Automatic exchange rates via OpenExchangeRates, per-currency markup and rounding, a Liquid fx_context bundle, and a zero-decimal-currency-aware rounding pipeline.
Overview
Multi-Currency lets visitors see prices in their preferred currency. Cartly fetches live rates from OpenExchangeRates every 6 hours via a Temporal cron workflow. Each currency is configured with a mode (auto or manual), a markup percentage (0-25%), and a rounding strategy.
Backend Modules
internal/fxrates— OpenExchangeRates provider, Temporal cron, ApplySnapshot, rounding pipelineinternal/markets/pricing.go— per-rate inherit/override merge for rounding strategycmd/migrate-fx-rates-nullable— legacy 0/none → NULL backfill (idempotent, already ran on prod)
Admin API
GET /admin/currencies— list all currencies with rates and stale flagsPUT /admin/currencies/:code— upsert a currency (mode, markup_pct, rounding_strategy, active)DELETE /admin/currencies/:code— remove a non-base currencyPOST /admin/currencies/refresh— force-refresh rates (rate-limited 5/min/IP)
Liquid Context
Every Liquid render receives shop.fx_context as an engine global (available inside {% render %} snippets too, not just the parent template). Shape: { display_currency, rates: { [code]: float }, supported_currencies: [{ code, rate, markup_pct, rounding_strategy }], stale: bool }.
Rounding Strategies
none— raw converted amountnearest_99— e.g. €18.40 → €17.99nearest_95— e.g. €18.40 → €17.95nearest_whole— e.g. €18.40 → €18
A per-currency row with rounding_strategy: "none" is respected even when the shop default is nearest_99 (explicit override wins).
Zero-Decimal Currencies
JPY, KRW, HUF, ISK, CLP, VND, BIF, RWF, XAF, XOF skip the cents-math rounding step. A ¥3,714 price stays ¥3,714.
Storefront JS
boilerplate/assets/fx-converter.js exposes window.cartlyFX.convertCents(amount, rate, markup_pct, rounding_strategy, currency) and window.cartlyFX.applyMarkupAndRounding(amount, markup_pct, rounding_strategy, currency) for client-side currency pickers.