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.
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.
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:
- A merchant changes a region's
currency_codein the admin after go-live, but no one adds price rows for the new currency, so the price set still only has the old ones. - A price list scoped to a different currency is still
activeand its rules still match the region or customer group, so it outranks the base price even though its currency does not match the region anymore. - A migration or a bad seed script creates a duplicate price row under the wrong
currency_code, so the price set looks complete but the amount under the region's real currency was never written. - A variant is missing a price row for the region's currency entirely, and
calculated_pricestill returns whichever row is eligible under the remaining context rules instead of returning nothing.
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.
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.
Build it step by step
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.
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
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
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.
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
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;
}
}
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.
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
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;
}
}
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.
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
/**
* 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;
}
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.
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"]
async function listActivePriceLists() {
const { price_lists } = await sdk.admin.priceList.list({
"status[]": "active",
fields: "id,title,status,*prices,*rules",
limit: 100,
});
return price_lists;
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- Storefront currency does not change even after changing it in backend. Medusa GitHub Issue #5684. github.com/medusajs/medusa/issues/5684
- Region and Currency code options missing on Price List. Medusa GitHub Issue #6670. github.com/medusajs/medusa/issues/6670
- 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:
- Medusa Documentation: Region Module. docs.medusajs.com/resources/commerce-modules/region
- Medusa Documentation: Prices Calculation, Pricing Module. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
- 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.
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