3 MIN READ

Multi-Currency Pricing in Medusa: Price Lists and Rounding

Explicit prices per currency, price lists for sales and customer-group pricing, and the minor-unit arithmetic that makes zero-decimal currencies interesting.

BY SHUBHAM VERMAUPDATED
Illustration for “Multi-Currency Pricing in Medusa: Price Lists and Rounding” — Internationalisation

Medusa stores an explicit amount per currency. There is no base currency and no conversion, which surprises people arriving from platforms that convert automatically — and is the right design, because international pricing is a commercial decision.

What does need care is the arithmetic: minor units, zero-decimal currencies, and rounding that keeps prices looking deliberate.

Minor units

Every amount is an integer in the smallest unit of its currency:

DisplayStoredCurrency
$49.004900USD, 2 decimals
€45.004500EUR, 2 decimals
¥4,9004900JPY, 0 decimals
KD 12.34512345KWD, 3 decimals

Integers avoid floating-point drift, which is not a theoretical concern — 0.1 + 0.2 in JavaScript is famously not 0.3, and a cent of drift per line across a year of orders is a reconciliation problem.

Zero-decimal currencies are the trap. Dividing by 100 to display is wrong for JPY, KRW and several others. Use the runtime's own knowledge:

lib/format-price.tsts
export function formatPrice(amount: number, currencyCode: string, locale = "en-US") {
  const formatter = new Intl.NumberFormat(locale, {
    style: "currency",
    currency: currencyCode.toUpperCase(),
  })

  const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2
  return formatter.format(amount / 10 ** decimals)
}

formatPrice(4900, "usd")           // "$49.00"
formatPrice(4900, "jpy", "ja-JP")  // "¥4,900"
formatPrice(12345, "kwd", "ar-KW") // "د.ك 12.345"

Never hardcode /100.

Setting prices

ts
{
  title: "Leather wallet",
  variants: [
    {
      title: "Brown",
      sku: "WALLET-BR",
      prices: [
        { amount: 4900, currency_code: "usd" },
        { amount: 4500, currency_code: "eur" },
        { amount: 3900, currency_code: "gbp" },
        { amount: 7400, currency_code: "aud" },
      ],
    },
  ],
}

A missing currency means the product is unbuyable in that region — it will simply not appear as purchasable, with no error. Add a launch check that every active variant has a price in every active currency; it catches a class of silent revenue loss.

Do not convert, round

Converting $49 at the day's rate gives €45.83. Nobody prices like that.

CurrencyConvertedPriced
USD$49.00$49.00
EUR€45.83€45.00
GBP£38.71£39.00
JPY¥7,341¥7,300

Round to the convention of the market: .00 or .99 in most Western markets, hundreds in JPY. If you have many SKUs, generate a first pass by conversion and rounding, then have someone review the result — never publish the raw conversion.

Price lists

Price lists override base prices for a scope, and they are how sales, customer-group pricing and contract rates are expressed:

ts
await pricingService.createPriceLists([
  {
    title: "Summer sale",
    type: "sale",
    starts_at: new Date("2026-07-01"),
    ends_at: new Date("2026-07-31"),
    prices: [
      { amount: 3900, currency_code: "usd", variant_id: "variant_123" },
      { amount: 3500, currency_code: "eur", variant_id: "variant_123" },
    ],
  },
])

Types:

  • sale — time-bounded discount, shown as a strikethrough against the base price.
  • override — replaces the price for a scope, typically a customer group. This is how B2B contract pricing works.

Price lists scope to customer groups, so a wholesale group sees its negotiated rates while retail customers see base prices — with no conditionals in the storefront.

Tax interaction

In an inclusive-tax region, the price you enter is the gross price. €45.00 inclusive at 20% VAT is €37.50 net plus €7.50 tax.

That means the same product in an inclusive EU region and an exclusive US region needs prices that are not simple conversions of each other, because one includes tax and the other does not. Set both deliberately. Tax configuration.

Display in the storefront

tsx
const price = variant.calculated_price
if (!price) return null

const isOnSale =
  price.calculated_amount < (price.original_amount ?? price.calculated_amount)

return (
  <p>
    <span>{formatPrice(price.calculated_amount, price.currency_code!)}</span>
    {isOnSale && (
      <s aria-label="Original price">
        {formatPrice(price.original_amount!, price.currency_code!)}
      </s>
    )}
  </p>
)

calculated_price requires a region or country context in the request, or it comes back null — the most common cause of missing prices in a storefront. SDK guide.

Practical guidance

Audit currency coverage before launch. One query, and it prevents unbuyable products.

Use Intl.NumberFormat everywhere. Symbol placement, separators and decimal counts vary more than you think.

Review generated prices. Bulk conversion is a starting point, not a publish.

Watch rounding on discounts. A 15% discount on 4900 is 4165, not 4160 — round consistently and match your accounting.

A price coverage audit

The check that prevents silently unbuyable products. Run it before every market launch and in CI if you can:

src/scripts/audit-price-coverage.tsts
import { ExecArgs } from "@medusajs/framework/types"

const REQUIRED = ["usd", "eur", "gbp"]

export default async function auditPriceCoverage({ container }: ExecArgs) {
  const query = container.resolve("query")
  const logger = container.resolve("logger")

  const { data: products } = await query.graph({
    entity: "product",
    fields: ["id", "title", "status", "variants.id", "variants.title", "variants.prices.currency_code"],
    filters: { status: "published" },
  })

  let gaps = 0

  for (const product of products) {
    for (const variant of product.variants ?? []) {
      const have = new Set((variant.prices ?? []).map((p: any) => p.currency_code))
      const missing = REQUIRED.filter((c) => !have.has(c))
      if (missing.length) {
        gaps++
        logger.warn(`[prices] ${product.title} / ${variant.title}: missing ${missing.join(", ")}`)
      }
    }
  }

  logger.info(`[prices] ${gaps} variant(s) with gaps`)
}

A missing price produces no error — the product simply cannot be bought in that region. This script is the only thing that finds it.

Bulk price updates

For a repricing exercise across a large catalog, generate the new prices, review them, then apply:

  1. Export current prices to CSV with product, variant, currency and amount.
  2. Compute new prices — conversion plus rounding rules per market.
  3. Review in a spreadsheet. Someone commercial should see the list before it goes live.
  4. Apply through a script in batches, with a dry-run flag that prints the diff.
  5. Spot check twenty products in the storefront across every region.

The review step is not optional. Automated repricing has published €0.01 products more than once, and the storefront will happily sell them.

Displaying prices before a region is known

The first render of a landing page often happens before you know the visitor's country, and the options each have a cost.

Default to a primary region. Simplest, and some visitors briefly see the wrong currency.

Infer from the request using a geo header from your CDN, then let the customer override. Best balance, and it makes the page dynamic unless you cache per country.

Ask first. A region selector before browsing. Honest, and it adds a step nobody enjoys.

Most stores land on inference with a persistent override in a cookie, and cache per country at the edge so the page stays static. Whatever you choose, never show a converted approximation with a disclaimer — customers read the number, not the note.


Multi-currency is arithmetic plus judgement. We are happy to check both.

Frequently asked questions

How does Medusa handle multiple currencies?

With an explicit amount per currency stored as an integer in that currency's minor unit. There is no base currency and no automatic conversion, so each market's price is a deliberate commercial decision.

Why are Medusa prices stored as integers?

To avoid floating-point rounding errors. Amounts are held in the currency's smallest unit — cents for USD, yen for JPY — so arithmetic stays exact and does not accumulate drift across orders.

How do I handle zero-decimal currencies like JPY?

Do not divide by 100. Ask `Intl.NumberFormat` for the currency's fraction digits and divide by ten to that power, so JPY divides by one and KWD by a thousand.

What are price lists in Medusa?

Scoped overrides on base prices. A `sale` list applies a time-bounded discount shown against the original price; an `override` list replaces prices for a scope such as a customer group, which is how wholesale and contract pricing work.

Should I convert prices automatically between currencies?

No. Convert as a starting point, then round to each market's psychological price points and have someone review. Raw conversion produces prices like €45.83 and makes your catalog move with the exchange rate.

Why is calculated_price null in my storefront?

Because the request has no region or country context, so Medusa cannot determine which price applies. Pass `region_id` or a country code, and request `*variants.calculated_price` in the `fields` parameter.

[ Keep reading ]