Diagnostic Regions, pricing, and currency

Wrong currency shown for a region

A merchant sets the region's currency to INR and adds INR prices, but the storefront keeps showing EUR. Nothing crashed and no error was logged, the number on the page just belongs to a different currency than the label next to it. Here is why a Region's currency_code and the price a customer actually sees can disagree in Medusa v2, and a script that finds every variant where that has happened so you can fix it with real numbers, not a guess.

Python and Node.js Medusa Admin API Flag and report, never guess an amount
The short answer

In Medusa v2 a Region has exactly one currency_code, but the price a customer sees comes from a completely separate Pricing Module record: a price row on a price_set linked to the variant. calculated_price resolves the region's currency_code and filters the price set for a matching row. If the region's currency changed after prices were seeded, if a price list scoped to another currency is still active and matches the context rules, or if no price exists for the region's real currency, the resolver falls through to a different currency price and renders it under the region's symbol. Run a script that compares every variant's calculated_price.currency_code against region.currency_code and flags the mismatches. Full code, tests, and a dry run guard are below.

The problem in plain words

It is easy to assume a region's currency and the prices shown in that region are the same thing in Medusa. They are not. The Region Module only stores a label, currency_code, that says what a region should charge in. The actual number a shopper sees comes from the Pricing Module, which stores independent price rows, each with its own currency_code and amount, grouped into a price_set attached to the variant.

Nothing keeps those two in sync automatically. When a cart or the storefront asks for a price, Medusa's calculatePrices logic reads the region's currency_code, then searches the variant's price set for a row that matches it and any other active context rules, such as a price list. If more than one row is eligible, it picks the best match by its own rules. If the region's currency was changed after prices were originally seeded, or an old price list in a different currency is still active with a matching rule, or no row for the new currency was ever added, the resolver still returns a price, just not one in the currency the region claims. That price is then rendered next to the region's currency symbol, so the label and the number quietly disagree.

Region currency_code: INR price_set on variant rows: EUR 42.00 rows: USD 45.00 (no INR row) calculated_price resolver falls through to EUR row calculated_price currency: EUR Shown as INR 42
The region label says INR, but no price row exists for INR, so the resolver falls through to the EUR row and the storefront shows that amount under the INR symbol.

Why it happens

The Region Module and the Pricing Module are two separate stores of truth, and Medusa never forces them to agree. A few common ways stores end up here:

This is a real, reported failure mode, not a hypothetical: merchants have hit exactly the "storefront currency does not change even after changing it in backend" and "calculated_price context works only with price lists and does not take other prices into consideration" behavior in production. See the citations at the end for the exact issues and docs.

The key insight

Never auto-convert an amount to fix this. A script that multiplies by an FX rate to "match" the region's currency would silently corrupt a real price with a guess. The only thing that is always safe is to compare calculated_price.currency_code and the raw price rows against region.currency_code, and flag every disagreement for a human. Repair is only automatic in the one unambiguous case: a variant is simply missing a price row in the region's currency and a verified same-amount source already exists.

The fix, as a flow

We do not touch any amount blind. The job walks every region, pulls the calculated price and the raw price rows for its variants, and runs a pure decision function that classifies each variant as fine, a calculated mismatch, or a missing currency row. Only the narrow, unambiguous missing-row case with a confirmed amount is ever written, and only when DRY_RUN is off.

List regions id, currency_code Read variant prices calculated_price + raw prices Pure decision fn findCurrencyMismatches Missing row, amount confirmed? yes, dry run off no, report only Flagged report sent to the merchant Write price row only this narrow case
Every mismatch is reported. Only the narrow, human-confirmed missing-row case is ever written, and only with DRY_RUN off.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false to write
2

Enumerate every region and its declared currency

Ask for regions with their currency_code and paginate with limit and offset. This is the label side of the comparison, the currency the region claims to charge in.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_regions(token):
    regions = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/regions", {
            "fields": "id,name,currency_code,*countries",
            "limit": limit,
            "offset": offset,
        })
        regions.extend(data["regions"])
        offset += limit
        if offset >= data["count"]:
            return regions
step2.js
import Medusa from "@medusajs/js-sdk";

const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });

async function listRegions() {
  const regions = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { regions: page, count } = await sdk.admin.region.list({
      fields: "id,name,currency_code,*countries",
      limit,
      offset,
    });
    regions.push(...page);
    offset += limit;
    if (offset >= count) return regions;
  }
}
3

Pull the calculated price and the raw price rows for each variant

For each region, list its products with calculated_price resolved in that region's context, and separately expand every raw price row on each variant's price set. The calculated price is what the storefront actually shows. The raw rows are every currency actually stored, including the ones that never get picked.

step3.py
def list_variant_prices_for_region(token, region_id):
    """Returns a list of {variant_id, product_id, prices, calculated_price}."""
    variant_prices = []
    offset = 0
    limit = 50
    while True:
        data = admin_get(token, "/admin/products", {
            "region_id": region_id,
            "fields": "id,title,*variants,*variants.calculated_price,*variants.prices",
            "limit": limit,
            "offset": offset,
        })
        for product in data["products"]:
            for variant in product.get("variants") or []:
                variant_prices.append({
                    "variant_id": variant["id"],
                    "product_id": product["id"],
                    "prices": variant.get("prices") or [],
                    "calculated_price": variant.get("calculated_price"),
                })
        offset += limit
        if offset >= data["count"]:
            return variant_prices
step3.js
async function listVariantPricesForRegion(regionId) {
  const variantPrices = [];
  let offset = 0;
  const limit = 50;
  while (true) {
    const { products, count } = await sdk.admin.product.list({
      region_id: regionId,
      fields: "id,title,*variants,*variants.calculated_price,*variants.prices",
      limit,
      offset,
    });
    for (const product of products) {
      for (const variant of product.variants || []) {
        variantPrices.push({
          variant_id: variant.id,
          product_id: product.id,
          prices: variant.prices || [],
          calculated_price: variant.calculated_price,
        });
      }
    }
    offset += limit;
    if (offset >= count) return variantPrices;
  }
}
4

Decide, with one pure function

Keep the comparison in its own function that takes a region and the fetched variant prices and returns the flagged list. It never touches the network, so it is trivial to unit test. A variant is flagged as a calculated_mismatch when the price the storefront resolved does not match the region's currency, and as missing_currency_row when no raw price row for that currency exists at all.

decide.py
def find_currency_mismatches(region, variant_prices):
    """Pure decision function. No I/O.

    region: {"id": str, "currency_code": str}
    variant_prices: [{
        "variant_id": str, "product_id": str,
        "prices": [{"id": str, "currency_code": str, "amount": float, "price_list_id": str | None}],
        "calculated_price": {"currency_code": str, "price_list_id": str | None} | None,
    }, ...]

    Returns a list of findings: {product_id, variant_id, region_id,
    expected_currency, shown_currency, price_id, price_list_id, reason}.
    """
    findings = []
    for vp in variant_prices:
        calculated = vp.get("calculated_price")
        if calculated and calculated.get("currency_code") != region["currency_code"]:
            findings.append({
                "product_id": vp["product_id"],
                "variant_id": vp["variant_id"],
                "region_id": region["id"],
                "expected_currency": region["currency_code"],
                "shown_currency": calculated["currency_code"],
                "price_id": None,
                "price_list_id": calculated.get("price_list_id"),
                "reason": "calculated_mismatch",
            })
            continue

        rows = vp.get("prices") or []
        matching_row = next((p for p in rows if p["currency_code"] == region["currency_code"]), None)
        if matching_row is None:
            nearest = rows[0] if rows else {}
            findings.append({
                "product_id": vp["product_id"],
                "variant_id": vp["variant_id"],
                "region_id": region["id"],
                "expected_currency": region["currency_code"],
                "shown_currency": nearest.get("currency_code"),
                "price_id": nearest.get("id"),
                "price_list_id": nearest.get("price_list_id"),
                "reason": "missing_currency_row",
            })
    return findings
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, currency_code: string }} region
 * @param {Array<{ variant_id: string, product_id: string,
 *   prices: Array<{ id: string, currency_code: string, amount: number, price_list_id?: string|null }>,
 *   calculated_price?: { currency_code: string, price_list_id?: string|null } | null }>} variantPrices
 * @returns {Array<{ product_id: string, variant_id: string, region_id: string,
 *   expected_currency: string, shown_currency: string, price_id?: string,
 *   price_list_id?: string|null, reason: "calculated_mismatch"|"missing_currency_row" }>}
 */
export function findCurrencyMismatches(region, variantPrices) {
  const findings = [];
  for (const vp of variantPrices) {
    const calculated = vp.calculated_price;
    if (calculated && calculated.currency_code !== region.currency_code) {
      findings.push({
        product_id: vp.product_id,
        variant_id: vp.variant_id,
        region_id: region.id,
        expected_currency: region.currency_code,
        shown_currency: calculated.currency_code,
        price_id: undefined,
        price_list_id: calculated.price_list_id,
        reason: "calculated_mismatch",
      });
      continue;
    }

    const rows = vp.prices || [];
    const matchingRow = rows.find((p) => p.currency_code === region.currency_code);
    if (!matchingRow) {
      const nearest = rows[0] || {};
      findings.push({
        product_id: vp.product_id,
        variant_id: vp.variant_id,
        region_id: region.id,
        expected_currency: region.currency_code,
        shown_currency: nearest.currency_code,
        price_id: nearest.id,
        price_list_id: nearest.price_list_id,
        reason: "missing_currency_row",
      });
    }
  }
  return findings;
}
5

Cross-check active price lists

A price list scoped to another currency can outrank the base price even when it should not apply to this region anymore. Pull each active list's prices and rules and flag any list price whose currency_code disagrees with the region it matches, so a stale sale price list in the wrong currency does not hide behind a passing check.

step5.py
def list_active_price_lists(token):
    data = admin_get(token, "/admin/price-lists", {
        "status[]": "active",
        "fields": "id,title,status,*prices,*rules",
        "limit": 100,
    })
    return data["price_lists"]
step5.js
async function listActivePriceLists() {
  const { price_lists } = await sdk.admin.priceList.list({
    "status[]": "active",
    fields: "id,title,status,*prices,*rules",
    limit: 100,
  });
  return price_lists;
}
6

Wire it together and report, never silently rewrite

The loop ties every piece together: list regions, list variant prices per region, run the pure decision function, and log every finding. Nothing is ever written except the one narrow case: a variant is missing a price row for the region's currency, an unambiguous same-amount source exists, and a human already confirmed the amount by setting CONFIRMED_AMOUNTS. Everything else, especially any real FX discrepancy, is reported to the merchant instead of touched.

Run it safe

Always start with DRY_RUN=true. This script never converts an amount with an FX rate. It only ever writes a price row when the amount for that currency has already been verified by a human and passed in explicitly. Every other finding, including any real currency mismatch, goes into the report for the merchant to review.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every region, flags every mismatch, and only ever writes the one verified, unambiguous repair case when DRY_RUN is off.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

find_currency_mismatches.py
"""Flag Medusa variants whose shown price currency disagrees with the region's currency_code.

A Region has exactly one currency_code, but the price a customer sees comes from a
separate Pricing Module record: a price row on a price_set linked to the variant.
calculated_price resolves the region's currency_code and filters the price set for a
matching row. If the region's currency changed after prices were seeded, if a price
list scoped to another currency is still active, or if no price exists for the
region's real currency, the resolver falls through to a different currency and the
storefront renders it under the region's symbol. This never auto-converts an amount.
It only ever writes a price row in the one confirmed, unambiguous case: a variant is
missing a row for the region's currency and a human already verified the amount.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_currency_mismatches")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Optional, human-confirmed amounts for the narrow missing_currency_row repair case.
# Format: "variant_id:currency_code:amount,variant_id:currency_code:amount"
# Example: "variant_01ABC:inr:1499.00"
CONFIRMED_AMOUNTS = {}
for entry in os.environ.get("CONFIRMED_AMOUNTS", "").split(","):
    entry = entry.strip()
    if not entry:
        continue
    variant_id, currency_code, amount = entry.split(":")
    CONFIRMED_AMOUNTS[(variant_id, currency_code.lower())] = float(amount)


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def admin_post(token, path, body):
    r = requests.post(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_currency_mismatches(region, variant_prices):
    """Pure decision function. No I/O.

    region: {"id": str, "currency_code": str}
    variant_prices: [{
        "variant_id": str, "product_id": str,
        "prices": [{"id": str, "currency_code": str, "amount": float, "price_list_id": str | None}],
        "calculated_price": {"currency_code": str, "price_list_id": str | None} | None,
    }, ...]

    Returns a list of findings: {product_id, variant_id, region_id,
    expected_currency, shown_currency, price_id, price_list_id, reason}.
    """
    findings = []
    for vp in variant_prices:
        calculated = vp.get("calculated_price")
        if calculated and calculated.get("currency_code") != region["currency_code"]:
            findings.append({
                "product_id": vp["product_id"],
                "variant_id": vp["variant_id"],
                "region_id": region["id"],
                "expected_currency": region["currency_code"],
                "shown_currency": calculated["currency_code"],
                "price_id": None,
                "price_list_id": calculated.get("price_list_id"),
                "reason": "calculated_mismatch",
            })
            continue

        rows = vp.get("prices") or []
        matching_row = next((p for p in rows if p["currency_code"] == region["currency_code"]), None)
        if matching_row is None:
            nearest = rows[0] if rows else {}
            findings.append({
                "product_id": vp["product_id"],
                "variant_id": vp["variant_id"],
                "region_id": region["id"],
                "expected_currency": region["currency_code"],
                "shown_currency": nearest.get("currency_code"),
                "price_id": nearest.get("id"),
                "price_list_id": nearest.get("price_list_id"),
                "reason": "missing_currency_row",
            })
    return findings


def list_regions(token):
    regions = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/regions", {
            "fields": "id,name,currency_code,*countries",
            "limit": limit,
            "offset": offset,
        })
        regions.extend(data["regions"])
        offset += limit
        if offset >= data["count"]:
            return regions


def list_variant_prices_for_region(token, region_id):
    variant_prices = []
    offset = 0
    limit = 50
    while True:
        data = admin_get(token, "/admin/products", {
            "region_id": region_id,
            "fields": "id,title,*variants,*variants.calculated_price,*variants.prices",
            "limit": limit,
            "offset": offset,
        })
        for product in data["products"]:
            for variant in product.get("variants") or []:
                variant_prices.append({
                    "variant_id": variant["id"],
                    "product_id": product["id"],
                    "prices": variant.get("prices") or [],
                    "calculated_price": variant.get("calculated_price"),
                })
        offset += limit
        if offset >= data["count"]:
            return variant_prices


def add_confirmed_price_row(token, variant_id, currency_code, amount):
    return admin_post(token, f"/admin/products/variants/{variant_id}/prices", {
        "prices": [{"currency_code": currency_code, "amount": amount}],
    })


def run():
    token = get_admin_token()
    regions = list_regions(token)

    total_findings = 0
    total_repaired = 0
    for region in regions:
        region_ref = {"id": region["id"], "currency_code": region["currency_code"]}
        variant_prices = list_variant_prices_for_region(token, region["id"])
        findings = find_currency_mismatches(region_ref, variant_prices)

        for finding in findings:
            total_findings += 1
            log.warning(
                "Region %s (%s): variant %s expected=%s shown=%s reason=%s price_list_id=%s",
                region["id"], region["currency_code"], finding["variant_id"],
                finding["expected_currency"], finding["shown_currency"],
                finding["reason"], finding["price_list_id"],
            )

            if finding["reason"] != "missing_currency_row":
                continue

            key = (finding["variant_id"], finding["expected_currency"].lower())
            confirmed_amount = CONFIRMED_AMOUNTS.get(key)
            if confirmed_amount is None:
                log.info("  No confirmed amount for %s in %s. Flagged only, not repaired.", key[0], key[1])
                continue

            log.info(
                "  %s POST /admin/products/variants/%s/prices {\"currency_code\": \"%s\", \"amount\": %s}",
                "Would call" if DRY_RUN else "Calling",
                finding["variant_id"], finding["expected_currency"], confirmed_amount,
            )
            if not DRY_RUN:
                add_confirmed_price_row(token, finding["variant_id"], finding["expected_currency"], confirmed_amount)
            total_repaired += 1

    log.info(
        "Done. %d mismatch(es) flagged, %d %s.",
        total_findings, total_repaired, "to repair" if DRY_RUN else "repaired",
    )


if __name__ == "__main__":
    run()
find-currency-mismatches.js
/**
 * Flag Medusa variants whose shown price currency disagrees with the region's currency_code.
 *
 * A Region has exactly one currency_code, but the price a customer sees comes from a
 * separate Pricing Module record: a price row on a price_set linked to the variant.
 * calculated_price resolves the region's currency_code and filters the price set for a
 * matching row. If the region's currency changed after prices were seeded, if a price
 * list scoped to another currency is still active, or if no price exists for the
 * region's real currency, the resolver falls through to a different currency and the
 * storefront renders it under the region's symbol. This never auto-converts an amount.
 * It only ever writes a price row in the one confirmed, unambiguous case: a variant is
 * missing a row for the region's currency and a human already verified the amount.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/wrong-currency-shown-for-a-region/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Optional, human-confirmed amounts for the narrow missing_currency_row repair case.
// Format: "variant_id:currency_code:amount,variant_id:currency_code:amount"
function parseConfirmedAmounts(raw) {
  const map = new Map();
  for (const entry of (raw || "").split(",")) {
    const trimmed = entry.trim();
    if (!trimmed) continue;
    const [variantId, currencyCode, amount] = trimmed.split(":");
    map.set(`${variantId}:${currencyCode.toLowerCase()}`, Number(amount));
  }
  return map;
}

const CONFIRMED_AMOUNTS = parseConfirmedAmounts(process.env.CONFIRMED_AMOUNTS);

/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, currency_code: string }} region
 * @param {Array<{ variant_id: string, product_id: string,
 *   prices: Array<{ id: string, currency_code: string, amount: number, price_list_id?: string|null }>,
 *   calculated_price?: { currency_code: string, price_list_id?: string|null } | null }>} variantPrices
 * @returns {Array<{ product_id: string, variant_id: string, region_id: string,
 *   expected_currency: string, shown_currency: string, price_id?: string,
 *   price_list_id?: string|null, reason: "calculated_mismatch"|"missing_currency_row" }>}
 */
export function findCurrencyMismatches(region, variantPrices) {
  const findings = [];
  for (const vp of variantPrices) {
    const calculated = vp.calculated_price;
    if (calculated && calculated.currency_code !== region.currency_code) {
      findings.push({
        product_id: vp.product_id,
        variant_id: vp.variant_id,
        region_id: region.id,
        expected_currency: region.currency_code,
        shown_currency: calculated.currency_code,
        price_id: undefined,
        price_list_id: calculated.price_list_id,
        reason: "calculated_mismatch",
      });
      continue;
    }

    const rows = vp.prices || [];
    const matchingRow = rows.find((p) => p.currency_code === region.currency_code);
    if (!matchingRow) {
      const nearest = rows[0] || {};
      findings.push({
        product_id: vp.product_id,
        variant_id: vp.variant_id,
        region_id: region.id,
        expected_currency: region.currency_code,
        shown_currency: nearest.currency_code,
        price_id: nearest.id,
        price_list_id: nearest.price_list_id,
        reason: "missing_currency_row",
      });
    }
  }
  return findings;
}

async function getAdminToken() {
  const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function adminPost(token, path, body) {
  const res = await fetch(`${BACKEND_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
  return res.json();
}

async function listRegions(token) {
  const regions = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const data = await adminGet(token, "/admin/regions", {
      fields: "id,name,currency_code,*countries",
      limit,
      offset,
    });
    regions.push(...data.regions);
    offset += limit;
    if (offset >= data.count) return regions;
  }
}

async function listVariantPricesForRegion(token, regionId) {
  const variantPrices = [];
  let offset = 0;
  const limit = 50;
  while (true) {
    const data = await adminGet(token, "/admin/products", {
      region_id: regionId,
      fields: "id,title,*variants,*variants.calculated_price,*variants.prices",
      limit,
      offset,
    });
    for (const product of data.products) {
      for (const variant of product.variants || []) {
        variantPrices.push({
          variant_id: variant.id,
          product_id: product.id,
          prices: variant.prices || [],
          calculated_price: variant.calculated_price,
        });
      }
    }
    offset += limit;
    if (offset >= data.count) return variantPrices;
  }
}

async function addConfirmedPriceRow(token, variantId, currencyCode, amount) {
  return adminPost(token, `/admin/products/variants/${variantId}/prices`, {
    prices: [{ currency_code: currencyCode, amount }],
  });
}

export async function run() {
  const token = await getAdminToken();
  const regions = await listRegions(token);

  let totalFindings = 0;
  let totalRepaired = 0;
  for (const region of regions) {
    const regionRef = { id: region.id, currency_code: region.currency_code };
    const variantPrices = await listVariantPricesForRegion(token, region.id);
    const findings = findCurrencyMismatches(regionRef, variantPrices);

    for (const finding of findings) {
      totalFindings++;
      console.warn(
        `Region ${region.id} (${region.currency_code}): variant ${finding.variant_id} expected=${finding.expected_currency} shown=${finding.shown_currency} reason=${finding.reason} price_list_id=${finding.price_list_id}`
      );

      if (finding.reason !== "missing_currency_row") continue;

      const key = `${finding.variant_id}:${finding.expected_currency.toLowerCase()}`;
      const confirmedAmount = CONFIRMED_AMOUNTS.get(key);
      if (confirmedAmount === undefined) {
        console.log(`  No confirmed amount for ${finding.variant_id} in ${finding.expected_currency}. Flagged only, not repaired.`);
        continue;
      }

      console.log(
        `  ${DRY_RUN ? "Would call" : "Calling"} POST /admin/products/variants/${finding.variant_id}/prices {"currency_code": "${finding.expected_currency}", "amount": ${confirmedAmount}}`
      );
      if (!DRY_RUN) {
        await addConfirmedPriceRow(token, finding.variant_id, finding.expected_currency, confirmedAmount);
      }
      totalRepaired++;
    }
  }

  console.log(`Done. ${totalFindings} mismatch(es) flagged, ${totalRepaired} ${DRY_RUN ? "to repair" : "repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

find_currency_mismatches is the part most worth testing, because it decides which variants get reported and which narrow case is even eligible for repair. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures already fetched, and checks the flagged list.

test_currency_mismatches.py
from find_currency_mismatches import find_currency_mismatches

REGION = {"id": "reg_in", "currency_code": "inr"}


def variant_price(**over):
    base = {
        "variant_id": "variant_1",
        "product_id": "prod_1",
        "prices": [{"id": "price_1", "currency_code": "inr", "amount": 1499.0, "price_list_id": None}],
        "calculated_price": {"currency_code": "inr", "price_list_id": None},
    }
    base.update(over)
    return base


def test_no_finding_when_currencies_match():
    assert find_currency_mismatches(REGION, [variant_price()]) == []


def test_calculated_mismatch_when_resolved_currency_differs():
    vp = variant_price(calculated_price={"currency_code": "eur", "price_list_id": "plist_1"})
    findings = find_currency_mismatches(REGION, [vp])
    assert findings == [{
        "product_id": "prod_1",
        "variant_id": "variant_1",
        "region_id": "reg_in",
        "expected_currency": "inr",
        "shown_currency": "eur",
        "price_id": None,
        "price_list_id": "plist_1",
        "reason": "calculated_mismatch",
    }]


def test_missing_currency_row_when_no_row_matches_region():
    vp = variant_price(
        calculated_price=None,
        prices=[{"id": "price_2", "currency_code": "eur", "amount": 42.0, "price_list_id": None}],
    )
    findings = find_currency_mismatches(REGION, [vp])
    assert findings == [{
        "product_id": "prod_1",
        "variant_id": "variant_1",
        "region_id": "reg_in",
        "expected_currency": "inr",
        "shown_currency": "eur",
        "price_id": "price_2",
        "price_list_id": None,
        "reason": "missing_currency_row",
    }]


def test_missing_currency_row_with_no_prices_at_all():
    vp = variant_price(calculated_price=None, prices=[])
    findings = find_currency_mismatches(REGION, [vp])
    assert findings[0]["reason"] == "missing_currency_row"
    assert findings[0]["shown_currency"] is None
    assert findings[0]["price_id"] is None


def test_calculated_mismatch_takes_priority_over_row_check():
    vp = variant_price(
        calculated_price={"currency_code": "usd", "price_list_id": None},
        prices=[{"id": "price_3", "currency_code": "inr", "amount": 1499.0, "price_list_id": None}],
    )
    findings = find_currency_mismatches(REGION, [vp])
    assert len(findings) == 1
    assert findings[0]["reason"] == "calculated_mismatch"


def test_multiple_variants_only_flags_the_mismatched_one():
    ok_vp = variant_price(variant_id="variant_ok")
    bad_vp = variant_price(variant_id="variant_bad", calculated_price={"currency_code": "eur", "price_list_id": None})
    findings = find_currency_mismatches(REGION, [ok_vp, bad_vp])
    assert len(findings) == 1
    assert findings[0]["variant_id"] == "variant_bad"
currency-mismatches.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findCurrencyMismatches } from "./find-currency-mismatches.js";

const REGION = { id: "reg_in", currency_code: "inr" };

const variantPrice = (over = {}) => ({
  variant_id: "variant_1",
  product_id: "prod_1",
  prices: [{ id: "price_1", currency_code: "inr", amount: 1499.0, price_list_id: null }],
  calculated_price: { currency_code: "inr", price_list_id: null },
  ...over,
});

test("no finding when currencies match", () => {
  assert.deepEqual(findCurrencyMismatches(REGION, [variantPrice()]), []);
});

test("calculated mismatch when resolved currency differs", () => {
  const vp = variantPrice({ calculated_price: { currency_code: "eur", price_list_id: "plist_1" } });
  const findings = findCurrencyMismatches(REGION, [vp]);
  assert.deepEqual(findings, [{
    product_id: "prod_1",
    variant_id: "variant_1",
    region_id: "reg_in",
    expected_currency: "inr",
    shown_currency: "eur",
    price_id: undefined,
    price_list_id: "plist_1",
    reason: "calculated_mismatch",
  }]);
});

test("missing currency row when no row matches region", () => {
  const vp = variantPrice({
    calculated_price: null,
    prices: [{ id: "price_2", currency_code: "eur", amount: 42.0, price_list_id: null }],
  });
  const findings = findCurrencyMismatches(REGION, [vp]);
  assert.deepEqual(findings, [{
    product_id: "prod_1",
    variant_id: "variant_1",
    region_id: "reg_in",
    expected_currency: "inr",
    shown_currency: "eur",
    price_id: "price_2",
    price_list_id: null,
    reason: "missing_currency_row",
  }]);
});

test("missing currency row with no prices at all", () => {
  const vp = variantPrice({ calculated_price: null, prices: [] });
  const findings = findCurrencyMismatches(REGION, [vp]);
  assert.equal(findings[0].reason, "missing_currency_row");
  assert.equal(findings[0].shown_currency, undefined);
  assert.equal(findings[0].price_id, undefined);
});

test("calculated mismatch takes priority over row check", () => {
  const vp = variantPrice({
    calculated_price: { currency_code: "usd", price_list_id: null },
    prices: [{ id: "price_3", currency_code: "inr", amount: 1499.0, price_list_id: null }],
  });
  const findings = findCurrencyMismatches(REGION, [vp]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].reason, "calculated_mismatch");
});

test("multiple variants only flags the mismatched one", () => {
  const okVp = variantPrice({ variant_id: "variant_ok" });
  const badVp = variantPrice({ variant_id: "variant_bad", calculated_price: { currency_code: "eur", price_list_id: null } });
  const findings = findCurrencyMismatches(REGION, [okVp, badVp]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0].variant_id, "variant_bad");
});

Case studies

Region currency changed after go-live

The merchant who switched to INR and forgot the price rows

A store launched in EUR, then decided to sell directly into India and changed the region's currency_code to INR. The admin let the change through with no warning, but nobody added INR price rows to the existing catalog. The storefront kept showing EUR amounts under an INR label, because that was the only row left in every price set.

Running the flag script on that region surfaced every affected variant as missing_currency_row in minutes. The merchant priced the catalog in INR by hand, confirmed each amount, and only then let the script write the rows with DRY_RUN=false.

Stale price list

A leftover EUR sale list kept winning over the base price

A seasonal price list scoped to a EUR promotion was left active long after the sale ended, and its rules still matched a region that had since been reconfigured for GBP. calculated_price kept picking the price list row over the base GBP price, so shoppers in a GBP region were quoted EUR-denominated numbers under a GBP label.

The cross-check on active price lists flagged the currency disagreement immediately. The team expired the stale price list, and the base GBP price took over on its own, no amount was touched by the script.

What good looks like

After this runs on a schedule, every region's claimed currency and its actual shown price agree, or you have a precise, per-variant report telling you exactly where they do not and why. No amount gets guessed or silently converted. The only automatic write is the one case that is genuinely unambiguous, and even that waits for a human to confirm the number first.

FAQ

Why does my Medusa region show the wrong currency for a product?

A Region has exactly one currency_code, but the price a customer sees comes from a separate Pricing Module record on the variant's price set. If the region's currency was changed after prices were seeded, if an old price list in a different currency is still active and matches the context rules, or if no price row exists for the region's actual currency, the resolver falls through to whichever price does match and renders it under the region's currency symbol.

Is it safe to auto-fix a currency mismatch in Medusa?

Not by converting the amount. Silently applying an FX rate to guess the right number would corrupt real prices, so this is a flag and report case. The only safe automatic repair is adding a missing price row when an unambiguous same-amount source already exists, for example a duplicate row created under the wrong currency_code by a bad migration, and only after a human confirms the amount.

What is the difference between a Region's currency_code and a price's currency_code in Medusa v2?

The Region Module stores one currency_code per region, which is just a label for what the storefront should charge in. The Pricing Module stores its own currency_code on every price row inside a price_set. calculated_price resolves the region's currency_code and searches the price_set for a matching row. Nothing keeps these two in sync automatically, so they can drift apart.

Related field notes

Citations

On the problem:

  1. Storefront currency does not change even after changing it in backend. Medusa GitHub Issue #5684. github.com/medusajs/medusa/issues/5684
  2. Region and Currency code options missing on Price List. Medusa GitHub Issue #6670. github.com/medusajs/medusa/issues/6670
  3. calculated_price context works only with price lists and does not take other prices into consideration. Medusa GitHub Issue #10613. github.com/medusajs/medusa/issues/10613

On the solution:

  1. Medusa Documentation: Region Module. docs.medusajs.com/resources/commerce-modules/region
  2. Medusa Documentation: Prices Calculation, Pricing Module. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
  3. Medusa Documentation: Multi-Region Store Recipe. docs.medusajs.com/resources/recipes/multi-region-store

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clear up a pricing mystery?

If this saved you from a support ticket about the wrong currency, or from a bad guess at an FX rate, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa field notes