# Multi Currency Pricing

> Use whenever an app needs to show prices in a visitor's local currency — localizing a SaaS pricing page, e-commerce checkout estimates, "show prices in EUR/IDR/JPY", adding a currency switcher, or any request to "make prices local"/"support multiple currencies". Covers the display-price architecture (fetch, cache, convert, round, format) built on exchangerate.dev, including the critical distinction between showing a localized estimate and actually charging in local currency.

- Skill: `nusantara-ventures/multi-currency-pricing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add nusantara-ventures/multi-currency-pricing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/nusantara-ventures/multi-currency-pricing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: nusantara-ventures (https://skillmd.com/u/nusantara-ventures)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/nusantara-ventures/multi-currency-pricing

---


# Multi-currency pricing

Two different things get called "multi-currency pricing," and conflating them causes real bugs (charging the wrong amount, promising a rate you can't honor):

1. **Display-only conversion** — canonical price stays in one base currency (e.g. USD); the page shows a localized *estimate* in the visitor's currency; the charge still happens in base currency. This is what most SaaS pricing pages and many checkout flows do.
2. **True multi-currency pricing** — the customer is actually charged in their local currency, at a rate set by your payment provider (Stripe, Adyen, etc.) at transaction time.

**This skill covers #1 — the display layer.** The charge rate always comes from the payment provider, never from an FX API you called for display. Never present the rate this skill computes as the transaction rate; label it an estimate and let the payment provider quote the real one at checkout. If the task is actually "charge in local currency," that's a payment-provider integration question, not an FX-API question — say so and stop building an FX-driven price engine for it.

## 1. Server-side rate fetch + cache — never call the FX API per page view

A pricing page rendered for every visitor must not trigger an API call per visitor. Fetch on a schedule, cache app-side, serve from cache.

For a short price list, batch-convert in one call instead of one call per currency:

```bash
curl -X POST "https://api.exchangerate.dev/v1/convert" \
  -H "Content-Type: application/json" \
  -d '{"from": "USD", "pairs": [["EUR", 29], ["GBP", 29], ["JPY", 29], ["IDR", 29]]}'
```

Body shape is `{"from": string, "pairs": [[symbol, amount], ...]}` — tuples, not objects. For a raw rate table instead (e.g. you have many SKUs and want to convert client-side), use `GET /v1/latest?base=USD&symbols=EUR,GBP,JPY,IDR` and cache the `rates` map.

**TTL — key it off `source`, not a flat number:**

- `source: live` (actively-traded currencies — check the per-currency `sources` map, don't hardcode the set) — minutes to hours is fine for display; these move fast but display doesn't need tick-level freshness.
- `source: ecb_daily` — refresh once, after the ~16:00 CET publish; refetching before that just returns yesterday's fix.
- `market_session: weekend` — nothing changes until Sydney open Monday morning local time; stop polling over the weekend, don't burn calls re-fetching an unchanged consensus.

At this cache cadence, keyless (12 req/min, IP-bucketed) is enough for local dev; in production get a free key (10,000 calls/month, `Authorization: Bearer $EXCHANGERATE_API_KEY`) — a daily scheduled fetch for a price list of a few dozen SKU/currency pairs uses a tiny fraction of that quota.

```
one scheduled job (cron / edge function)
      │
      ▼
GET/POST exchangerate.dev  →  cache (KV, Redis, or in-memory)
      │
      ▼
page render reads from cache — zero FX API calls per request
```

## 2. Price presentation — round to the target currency, then apply pricing psychology separately

Two rounding steps happen, and they are not the same step:

**Step A — currency-correct rounding.** Round to the target currency's `minor_units` from `GET /v1/currencies`. JPY has `minor_units: 0` — there is no such thing as ¥2,900.00, only ¥2,900. Prefer the API's `converted` field (already minor-unit-rounded server-side) over multiplying `rate` yourself:

```bash
curl "https://api.exchangerate.dev/v1/convert/USD/JPY/29"
# converted: 4707  (whole yen — not 4707.16, minor_units is 0)
```

**Step B — charm pricing, applied AFTER conversion, as a business rule.** $29 → €26.54 at the raw rate. Presenting €26.54 looks like an unreviewed FX conversion, not a price. Deliberately re-price to your market's convention — €26.99 or €27, whichever your pricing strategy uses — the same way you'd choose $29 instead of $28.73 for the base price. Do NOT let the rounding-to-minor-units step (A) accidentally produce a psychologically-priced number and mistake that for intentional pricing; charm-price explicitly, on top of the correctly-rounded conversion.

Keep the canonical price in base currency in your source of truth (database, pricing config). Every localized price is a *derived, presentation-only* value — never store a converted price as if it were a base price; recompute it from the canonical value on each repricing pass.

## 3. Stability vs freshness — don't let prices flicker

Repricing on every rate tick makes the same plan show €27 on Monday and €27.10 on Tuesday for no reason a visitor can see. Common pattern:

- Reprice on a fixed schedule (daily or weekly), **or**
- Reprice on a threshold move (>1-2% since last reprice), with hysteresis (require the move to persist, not one noisy tick) so it doesn't reprice back and forth across a boundary.

Either way, label the number as an estimate, not a fixed price:

> Estimated at **≈ €27**, billed in USD — final amount may vary slightly at checkout.

## 4. Formatting

Format with the platform's locale formatter (`Intl.NumberFormat` in JS, `babel.numbers.format_currency` in Python) rather than hand-concatenating a symbol — symbol position, grouping, and decimal count vary per locale (`29,00 €` vs `¥2,900`). A bonus worth knowing: passing the currency code makes the formatter render the right number of decimals on its own (JPY with 0), so you don't hard-code `minor_units` a second time in the display layer.

## 5. Compliance and honesty

- Rates are indicative (~5-15 bps band), aggregated market data — fine for display, not a settlement rate. See `fx-rates-correctness` for why.
- Show the conversion basis somewhere near the price: a timestamp ("rates as of Jul 6") or static copy ("prices updated daily; you'll be billed in USD"). Don't imply the localized number is live-ticking if it's a cached daily snapshot.
- Checkout must restate the charge currency explicitly before payment — "You'll be charged $29.00 USD" — even though the page led with a EUR estimate. Never let the last currency a user saw be silently different from the currency they're charged in.

## 6. The pipeline in one line

Putting §1-4 together, a localized price is: **cached base→local rate → charm-price as a business rule → format with the locale formatter → label as an estimate.** The one ordering that matters and is easy to get wrong: charm-pricing happens *after* the currency-correct conversion (§2 Step B after Step A), never folded into it — and the canonical price stays in base currency, every localized figure recomputed from it on each repricing pass.

## Related skills in this repo

- `exchangerate-dev` — API reference: endpoints, auth, MCP server, error handling.
- `fx-rates-correctness` — the correctness pitfalls this skill leans on: precision/minor-units, staleness, `source`/`market_session` semantics, why indicative rates never touch settlement.
- `fx-accounting-rates` — once a sale is made, use this skill's rate-of-record conventions to invoice and book the converted amount correctly.

