Diagnostic Pricing & Promotions
Region scoped price ignored in favor of currency only price
You set up a variant with a cheaper (or pricier) price for one region, and a plain currency only price as the fallback for everyone else. The storefront in that region should show the region price. Instead it shows the plain currency price, as if the region rule was never there, even though the correct region_id and currency_code were passed in. Here is why Medusa's price selection resolver picks the wrong row and a script that finds every variant and region pair where this is happening.
In Medusa v2's Pricing Module, a variant's PriceSet can hold several Price rows: some scoped only by currency_code, others scoped by a PriceRule on region_id or on region_id plus currency_code together. The price selection algorithm first looks for a price whose rule set is an exact, complete match for the request context. When nothing matches every rule at once, it falls back to the price matching the most rules, and ties or partial matches resolve toward the plain currency only row instead of the region scoped one. Community reports, including GitHub issue #13120, confirm that once any price on the variant carries a region_id rule, calculated_price frequently returns null or silently reverts to the currency only default. The data is stored correctly, so this is flag and report, not auto-repair. Full detection code, a pure decision function, and the documented workaround are below.
The problem in plain words
A PriceSet on a Medusa variant is not one price, it is a small table of prices, and each row can carry rules that say when it applies. A plain row might say nothing more than "this is the price in USD." Another row can say "this is the price in USD, and only in this region." Both rows can sit on the same variant at the same time, which is exactly the setup you want when a region should get a different number than everyone else.
The trouble starts when Medusa's calculated_price context resolver has to pick one winner out of that table. It is supposed to prefer the price whose rules match the current shopper the most completely. But in practice, once a variant has a region scoped row sitting next to a currency only row, the resolver frequently backs away from the region row and hands back the currency only price instead, or returns null, even when the request carried the exact region_id and currency_code that should have matched the region row.
Why it happens
This traces back to how the price selection strategy ranks candidate prices, not to any mistake in how the prices were stored:
- Every Price row can carry a set of PriceRules, for example one rule where
attribute = "region_id"andvalue = <reg_id>, or a pair of rules combiningregion_idandcurrency_code. - The selection algorithm first tries to find a price whose entire rule set is satisfied at once by the given context. When no row satisfies every rule simultaneously, it falls back to the price that matches the most rules rather than failing outright.
- In that fallback comparison, ties or partial match edge cases resolve toward the plain currency only row rather than the region scoped one, which is the opposite of what a merchant setting a cheaper or pricier regional price expects.
- GitHub issue #13120 documents exactly this:
calculated_pricedefined by region is not obtained, and the resolver either returns null or reverts to the default, even with the correct region_id and currency_code supplied. - Issue #10613 reports a related shape of the same root cause, where
calculated_pricecontext only really works reliably with price list prices and does not weigh other price rule combinations correctly. - Issue #1175 is the older tracking issue asking that the price selection strategy make
region_idandcurrency_codeboth properly optional inputs to the match, which is the underlying design gap this bug sits on top of.
This is a common source of confusion because nothing about the setup looks wrong. The region price is there, saved correctly, visible in the admin. The request context is correct too, the exact region_id and currency_code go out over the wire. The mismatch only shows up when you compare the id of the price Medusa actually served against the id of the price row you expected, which most stores never do until a customer complains about being charged the wrong amount. See the citations at the end for the exact issues and docs.
This is a bug in the price selection engine, not in your data. The region scoped Price row is stored correctly and the request context is correct, so there is nothing to safely auto-fix by rewriting amounts. The one documented workaround is to make the region scoped row carry an explicit currency_code condition alongside its region_id rule, since a price that matches both rules at once outranks a currency only price in the resolver's own ranking. Detection has to compare the served calculated_price.id against the stored region price's own id, because a matching amount by coincidence would hide the bug.
The fix, as a flow
We never guess new amounts and we never delete a price row. The script walks every region, every product and variant, groups each variant's prices by currency, and flags the ones where a region scoped row and a currency only row for the same currency sit side by side. Then it cross-checks the Store API's actual served price against the region row's own id to confirm the bug is really hitting that pair, not just theoretically possible.
Build it step by step
Authenticate against the Admin API, and get a publishable key for the Store API
Exchange the admin email and password for a JWT at POST /auth/user/emailpass, and send it as Authorization: Bearer <token> on every admin call. The Store API cross-check also needs a publishable API key on the x-publishable-api-key header. Default to DRY_RUN=true, since this script only ever reports.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true" # start safe, only reports the mismatch check
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_PUBLISHABLE_KEY="pk_..."
export DRY_RUN="true" // start safe, only reports the mismatch check
Log in and list regions
Every region maps to one currency. Read that map first with GET /admin/regions?fields=id,name,currency_code,countries.iso_2, because the detection needs to know which currency to expect for each region when it checks a variant.
import os, requests
BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_regions(token):
r = requests.get(
f"{BASE}/admin/regions",
params={"fields": "id,name,currency_code,countries.iso_2"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
const BASE = process.env.MEDUSA_BACKEND_URL;
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function login() {
const res = await fetch(`${BASE}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function listRegions(token) {
const url = new URL(`${BASE}/admin/regions`);
url.searchParams.set("fields", "id,name,currency_code,countries.iso_2");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa regions ${res.status}`);
const body = await res.json();
return body.regions;
}
Pull every variant's prices and price rules
Page through products with offset and limit, expanding each variant's prices and their rules. We need the price id, amount, currency_code, and the attribute and value of every PriceRule attached to it, because that is what the decision function compares.
PRODUCT_FIELDS = (
"id,title,*variants,"
"variants.prices.id,variants.prices.amount,variants.prices.currency_code,"
"variants.prices.rules_count,"
"variants.prices.price_rules.attribute,variants.prices.price_rules.value"
)
def iter_products(token):
offset = 0
while True:
r = requests.get(
f"{BASE}/admin/products",
params={"fields": PRODUCT_FIELDS, "offset": offset, "limit": 50},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += body["limit"]
if offset >= body["count"]:
return
const PRODUCT_FIELDS = [
"id,title,*variants",
"variants.prices.id,variants.prices.amount,variants.prices.currency_code",
"variants.prices.rules_count",
"variants.prices.price_rules.attribute,variants.prices.price_rules.value",
].join(",");
async function* iterProducts(token) {
let offset = 0;
while (true) {
const url = new URL(`${BASE}/admin/products`);
url.searchParams.set("fields", PRODUCT_FIELDS);
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", "50");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa products ${res.status}`);
const body = await res.json();
for (const product of body.products) yield product;
offset += body.limit;
if (offset >= body.count) return;
}
}
Decide, with one pure function
Keep the ranking logic in a function with no network calls. It takes the variant's prices and a request context (region_id, currency_code) and returns the price it expects Medusa to pick: filter to prices whose rules are all satisfied by the context and whose currency matches, then prefer the price with the most matching rules, breaking ties toward the price that explicitly names region_id. This is the exact branch to reproduce issue #13120 against.
def _rule_satisfied(rule, context):
if rule["attribute"] == "region_id":
return rule["value"] == context.get("region_id")
if rule["attribute"] == "currency_code":
return rule["value"] == context.get("currency_code")
return False
def pick_winning_price(prices, context):
"""Pure. prices: list of {id, amount, currency_code, rules}. context: {region_id, currency_code}."""
candidates = [
p for p in prices
if p["currency_code"] == context.get("currency_code")
and all(_rule_satisfied(r, context) for r in p.get("rules") or [])
]
if not candidates:
return None
def sort_key(p):
rules = p.get("rules") or []
matched = sum(1 for r in rules if _rule_satisfied(r, context))
has_region_rule = any(r["attribute"] == "region_id" for r in rules)
return (matched, len(rules), 1 if has_region_rule else 0)
winner = max(candidates, key=sort_key)
return {"id": winner["id"], "amount": winner["amount"]}
function ruleSatisfied(rule, context) {
if (rule.attribute === "region_id") return rule.value === context.region_id;
if (rule.attribute === "currency_code") return rule.value === context.currency_code;
return false;
}
export function pickWinningPrice(prices, context) {
const candidates = prices.filter(
(p) =>
p.currency_code === context.currency_code &&
(p.rules || []).every((r) => ruleSatisfied(r, context))
);
if (!candidates.length) return null;
const sortKey = (p) => {
const rules = p.rules || [];
const matched = rules.filter((r) => ruleSatisfied(r, context)).length;
const hasRegionRule = rules.some((r) => r.attribute === "region_id");
return [matched, rules.length, hasRegionRule ? 1 : 0];
};
const winner = candidates.reduce((best, p) => {
const a = sortKey(p);
const b = sortKey(best);
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return a[i] > b[i] ? p : best;
}
return best;
}, candidates[0]);
return { id: winner.id, amount: winner.amount };
}
Cross-check the expected price against what the Store API actually serves
Storing the right data and expecting the right price is not proof the bug is live. Call the Store API with region_id set, using x-publishable-api-key, and compare calculated_price.id against the region scoped row's own id. A mismatch, where the served id equals the currency only row, confirms the bug is affecting that variant and region pair.
def served_calculated_price(publishable_key, product_id, region_id):
r = requests.get(
f"{BASE}/store/products/{product_id}",
params={"region_id": region_id, "fields": "*variants.calculated_price"},
headers={"x-publishable-api-key": publishable_key},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]["variants"]
async function servedCalculatedPrice(publishableKey, productId, regionId) {
const url = new URL(`${BASE}/store/products/${productId}`);
url.searchParams.set("region_id", regionId);
url.searchParams.set("fields", "*variants.calculated_price");
const res = await fetch(url, { headers: { "x-publishable-api-key": publishableKey } });
if (!res.ok) throw new Error(`Medusa store product ${res.status}`);
const body = await res.json();
return body.product.variants;
}
Wire it together and report, never mutate
The loop authenticates, lists regions, walks every product and variant, uses pick_winning_price to compute what should be served, fetches what the Store API actually serves, and records every {variant_id, region_id, currency_code, expected_price_id, expected_amount, served_price_id, served_amount} tuple where the two disagree. DRY_RUN stays true because this script is diagnostic. It never rewrites a price on its own.
Do not auto-mutate prices based on this report. The data is correct, the resolver is picking the wrong row, so the fix belongs with a human who reviews the flagged list and applies the documented workaround deliberately, then re-checks that the fix actually took effect.
The full code
Here is the complete script in one file for each language. It authenticates, lists regions, walks every product and variant, computes the expected winner with a pure function, cross-checks it against the Store API's served price, and logs every mismatch. It never mutates a price on its own.
"""Find Medusa v2 variants where a region scoped price is being ignored in
favor of a plain currency only price.
The Pricing Module's calculated_price resolver first looks for a price whose
rule set is an exact, complete match for the request context. When nothing
matches every rule at once it falls back to the price matching the most
rules, and ties or partial matches resolve toward the plain currency only
row instead of the region scoped one (medusajs/medusa#13120). The data is
stored correctly, so this script only reports. It never rewrites a price.
Guide: https://www.allanninal.dev/medusa/region-price-ignored/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_ignored_region_price")
BASE = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PRODUCT_FIELDS = (
"id,title,*variants,"
"variants.prices.id,variants.prices.amount,variants.prices.currency_code,"
"variants.prices.rules_count,"
"variants.prices.price_rules.attribute,variants.prices.price_rules.value"
)
def _rule_satisfied(rule, context):
if rule["attribute"] == "region_id":
return rule["value"] == context.get("region_id")
if rule["attribute"] == "currency_code":
return rule["value"] == context.get("currency_code")
return False
def pick_winning_price(prices, context):
"""Pure decision logic. No I/O.
prices: list of {id, amount, currency_code, rules: [{attribute, value}]}
context: {region_id, currency_code}
Returns {"id": ..., "amount": ...} or None if nothing matches.
"""
candidates = [
p for p in prices
if p["currency_code"] == context.get("currency_code")
and all(_rule_satisfied(r, context) for r in p.get("rules") or [])
]
if not candidates:
return None
def sort_key(p):
rules = p.get("rules") or []
matched = sum(1 for r in rules if _rule_satisfied(r, context))
has_region_rule = any(r["attribute"] == "region_id" for r in rules)
return (matched, len(rules), 1 if has_region_rule else 0)
winner = max(candidates, key=sort_key)
return {"id": winner["id"], "amount": winner["amount"]}
def login():
r = requests.post(
f"{BASE}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_regions(token):
r = requests.get(
f"{BASE}/admin/regions",
params={"fields": "id,name,currency_code,countries.iso_2"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
def iter_products(token):
offset = 0
while True:
r = requests.get(
f"{BASE}/admin/products",
params={"fields": PRODUCT_FIELDS, "offset": offset, "limit": 50},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += body["limit"]
if offset >= body["count"]:
return
def served_calculated_price(publishable_key, product_id, region_id):
r = requests.get(
f"{BASE}/store/products/{product_id}",
params={"region_id": region_id, "fields": "*variants.calculated_price"},
headers={"x-publishable-api-key": publishable_key},
timeout=30,
)
r.raise_for_status()
return r.json()["product"]["variants"]
def variant_price_dicts(variant):
out = []
for price in variant.get("prices") or []:
rules = [
{"attribute": rule["attribute"], "value": rule["value"]}
for rule in price.get("price_rules") or []
]
out.append({
"id": price["id"],
"amount": price["amount"],
"currency_code": price["currency_code"],
"rules": rules,
})
return out
def has_region_and_currency_only_pair(prices, region_id, currency_code):
has_region_row = any(
p["currency_code"] == currency_code
and any(r["attribute"] == "region_id" and r["value"] == region_id for r in p["rules"])
for p in prices
)
has_currency_only_row = any(
p["currency_code"] == currency_code and not p["rules"]
for p in prices
)
return has_region_row and has_currency_only_row
def run():
if not PUBLISHABLE_KEY:
log.warning("MEDUSA_PUBLISHABLE_KEY is not set. Skipping the Store API cross-check.")
token = login()
regions = list_regions(token)
flagged = 0
for product in iter_products(token):
for variant in product.get("variants") or []:
prices = variant_price_dicts(variant)
for region in regions:
region_id = region["id"]
currency_code = region["currency_code"]
if not has_region_and_currency_only_pair(prices, region_id, currency_code):
continue
context = {"region_id": region_id, "currency_code": currency_code}
expected = pick_winning_price(prices, context)
if expected is None:
continue
served = None
if PUBLISHABLE_KEY:
served_variants = {
v["id"]: v for v in served_calculated_price(PUBLISHABLE_KEY, product["id"], region_id)
}
served_variant = served_variants.get(variant["id"]) or {}
served = served_variant.get("calculated_price") or {}
served_id = served.get("id") if served else None
served_amount = served.get("calculated_amount") if served else None
if served_id is not None and served_id == expected["id"]:
continue
log.warning(
"variant=%s region=%s currency=%s expected_price_id=%s expected_amount=%s "
"served_price_id=%s served_amount=%s",
variant["id"], region_id, currency_code,
expected["id"], expected["amount"], served_id, served_amount,
)
flagged += 1
log.info("Done. %d variant/region pair(s) flagged for review. Dry run: %s", flagged, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Find Medusa v2 variants where a region scoped price is being ignored in
* favor of a plain currency only price.
*
* The Pricing Module's calculated_price resolver first looks for a price
* whose rule set is an exact, complete match for the request context. When
* nothing matches every rule at once it falls back to the price matching
* the most rules, and ties or partial matches resolve toward the plain
* currency only row instead of the region scoped one (medusajs/medusa#13120).
* The data is stored correctly, so this script only reports. It never
* rewrites a price.
*
* Guide: https://www.allanninal.dev/medusa/region-price-ignored/
*/
import { pathToFileURL } from "node:url";
const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PRODUCT_FIELDS = [
"id,title,*variants",
"variants.prices.id,variants.prices.amount,variants.prices.currency_code",
"variants.prices.rules_count",
"variants.prices.price_rules.attribute,variants.prices.price_rules.value",
].join(",");
function ruleSatisfied(rule, context) {
if (rule.attribute === "region_id") return rule.value === context.region_id;
if (rule.attribute === "currency_code") return rule.value === context.currency_code;
return false;
}
export function pickWinningPrice(prices, context) {
// Pure: no I/O. prices: [{id, amount, currency_code, rules}]. context: {region_id, currency_code}.
const candidates = prices.filter(
(p) =>
p.currency_code === context.currency_code &&
(p.rules || []).every((r) => ruleSatisfied(r, context))
);
if (!candidates.length) return null;
const sortKey = (p) => {
const rules = p.rules || [];
const matched = rules.filter((r) => ruleSatisfied(r, context)).length;
const hasRegionRule = rules.some((r) => r.attribute === "region_id");
return [matched, rules.length, hasRegionRule ? 1 : 0];
};
const winner = candidates.reduce((best, p) => {
const a = sortKey(p);
const b = sortKey(best);
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return a[i] > b[i] ? p : best;
}
return best;
}, candidates[0]);
return { id: winner.id, amount: winner.amount };
}
async function login() {
const res = await fetch(`${BASE}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function listRegions(token) {
const url = new URL(`${BASE}/admin/regions`);
url.searchParams.set("fields", "id,name,currency_code,countries.iso_2");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa regions ${res.status}`);
const body = await res.json();
return body.regions;
}
async function* iterProducts(token) {
let offset = 0;
while (true) {
const url = new URL(`${BASE}/admin/products`);
url.searchParams.set("fields", PRODUCT_FIELDS);
url.searchParams.set("offset", String(offset));
url.searchParams.set("limit", "50");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa products ${res.status}`);
const body = await res.json();
for (const product of body.products) yield product;
offset += body.limit;
if (offset >= body.count) return;
}
}
async function servedCalculatedPrice(publishableKey, productId, regionId) {
const url = new URL(`${BASE}/store/products/${productId}`);
url.searchParams.set("region_id", regionId);
url.searchParams.set("fields", "*variants.calculated_price");
const res = await fetch(url, { headers: { "x-publishable-api-key": publishableKey } });
if (!res.ok) throw new Error(`Medusa store product ${res.status}`);
const body = await res.json();
return body.product.variants;
}
function variantPriceDicts(variant) {
return (variant.prices || []).map((price) => ({
id: price.id,
amount: price.amount,
currency_code: price.currency_code,
rules: (price.price_rules || []).map((rule) => ({ attribute: rule.attribute, value: rule.value })),
}));
}
export function hasRegionAndCurrencyOnlyPair(prices, regionId, currencyCode) {
const hasRegionRow = prices.some(
(p) =>
p.currency_code === currencyCode &&
p.rules.some((r) => r.attribute === "region_id" && r.value === regionId)
);
const hasCurrencyOnlyRow = prices.some((p) => p.currency_code === currencyCode && p.rules.length === 0);
return hasRegionRow && hasCurrencyOnlyRow;
}
export async function run() {
if (!PUBLISHABLE_KEY) {
console.warn("MEDUSA_PUBLISHABLE_KEY is not set. Skipping the Store API cross-check.");
}
const token = await login();
const regions = await listRegions(token);
let flagged = 0;
for await (const product of iterProducts(token)) {
for (const variant of product.variants || []) {
const prices = variantPriceDicts(variant);
for (const region of regions) {
const regionId = region.id;
const currencyCode = region.currency_code;
if (!hasRegionAndCurrencyOnlyPair(prices, regionId, currencyCode)) continue;
const context = { region_id: regionId, currency_code: currencyCode };
const expected = pickWinningPrice(prices, context);
if (!expected) continue;
let served = null;
if (PUBLISHABLE_KEY) {
const servedVariants = await servedCalculatedPrice(PUBLISHABLE_KEY, product.id, regionId);
const servedVariant = servedVariants.find((v) => v.id === variant.id) || {};
served = servedVariant.calculated_price || null;
}
const servedId = served ? served.id : null;
const servedAmount = served ? served.calculated_amount : null;
if (servedId !== null && servedId === expected.id) continue;
console.warn(
`variant=${variant.id} region=${regionId} currency=${currencyCode} ` +
`expected_price_id=${expected.id} expected_amount=${expected.amount} ` +
`served_price_id=${servedId} served_amount=${servedAmount}`
);
flagged++;
}
}
}
console.log(`Done. ${flagged} variant/region pair(s) flagged for review. Dry run: ${DRY_RUN}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing is the ranking logic, pick_winning_price. It is pure, so the tests build fixture prices and rules by hand, including the exact shape reported in issue #13120, a region plus currency price sitting next to a currency only price, and check which one wins.
from find_ignored_region_price import pick_winning_price, has_region_and_currency_only_pair
CONTEXT = {"region_id": "reg_eu", "currency_code": "eur"}
def currency_only(amount=1000, currency="eur"):
return {"id": "price_currency_only", "amount": amount, "currency_code": currency, "rules": []}
def region_scoped(amount=800, region="reg_eu", currency="eur"):
return {
"id": "price_region",
"amount": amount,
"currency_code": currency,
"rules": [{"attribute": "region_id", "value": region}],
}
def region_and_currency(amount=800, region="reg_eu", currency="eur"):
return {
"id": "price_region_currency",
"amount": amount,
"currency_code": currency,
"rules": [
{"attribute": "region_id", "value": region},
{"attribute": "currency_code", "value": currency},
],
}
def test_region_plus_currency_price_outranks_currency_only():
prices = [currency_only(), region_and_currency()]
winner = pick_winning_price(prices, CONTEXT)
assert winner == {"id": "price_region_currency", "amount": 800}
def test_region_only_price_still_outranks_currency_only_on_tie_break():
prices = [currency_only(), region_scoped()]
winner = pick_winning_price(prices, CONTEXT)
assert winner == {"id": "price_region", "amount": 800}
def test_wrong_region_rule_is_excluded():
prices = [currency_only(), region_scoped(region="reg_us")]
winner = pick_winning_price(prices, CONTEXT)
assert winner == {"id": "price_currency_only", "amount": 1000}
def test_wrong_currency_is_excluded_even_with_matching_region():
prices = [region_scoped(currency="usd")]
winner = pick_winning_price(prices, CONTEXT)
assert winner is None
def test_no_candidates_returns_none():
assert pick_winning_price([], CONTEXT) is None
def test_has_region_and_currency_only_pair_true_when_both_present():
prices = [currency_only(), region_scoped()]
assert has_region_and_currency_only_pair(prices, "reg_eu", "eur") is True
def test_has_region_and_currency_only_pair_false_when_only_one_present():
prices = [currency_only()]
assert has_region_and_currency_only_pair(prices, "reg_eu", "eur") is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickWinningPrice, hasRegionAndCurrencyOnlyPair } from "./find-ignored-region-price.js";
const CONTEXT = { region_id: "reg_eu", currency_code: "eur" };
const currencyOnly = (amount = 1000, currency = "eur") => ({
id: "price_currency_only",
amount,
currency_code: currency,
rules: [],
});
const regionScoped = (amount = 800, region = "reg_eu", currency = "eur") => ({
id: "price_region",
amount,
currency_code: currency,
rules: [{ attribute: "region_id", value: region }],
});
const regionAndCurrency = (amount = 800, region = "reg_eu", currency = "eur") => ({
id: "price_region_currency",
amount,
currency_code: currency,
rules: [
{ attribute: "region_id", value: region },
{ attribute: "currency_code", value: currency },
],
});
test("region plus currency price outranks currency only", () => {
const winner = pickWinningPrice([currencyOnly(), regionAndCurrency()], CONTEXT);
assert.deepEqual(winner, { id: "price_region_currency", amount: 800 });
});
test("region only price still outranks currency only on tie break", () => {
const winner = pickWinningPrice([currencyOnly(), regionScoped()], CONTEXT);
assert.deepEqual(winner, { id: "price_region", amount: 800 });
});
test("wrong region rule is excluded", () => {
const winner = pickWinningPrice([currencyOnly(), regionScoped(800, "reg_us")], CONTEXT);
assert.deepEqual(winner, { id: "price_currency_only", amount: 1000 });
});
test("wrong currency is excluded even with matching region", () => {
const winner = pickWinningPrice([regionScoped(800, "reg_eu", "usd")], CONTEXT);
assert.equal(winner, null);
});
test("no candidates returns null", () => {
assert.equal(pickWinningPrice([], CONTEXT), null);
});
test("hasRegionAndCurrencyOnlyPair true when both present", () => {
const prices = [currencyOnly(), regionScoped()];
assert.equal(hasRegionAndCurrencyOnlyPair(prices, "reg_eu", "eur"), true);
});
test("hasRegionAndCurrencyOnlyPair false when only one present", () => {
const prices = [currencyOnly()];
assert.equal(hasRegionAndCurrencyOnlyPair(prices, "reg_eu", "eur"), false);
});
Case studies
The store that meant to undercut one market
A homeware brand set a cheaper EUR price for its German region to compete locally, alongside its normal EUR price for the rest of the eurozone. The storefront kept showing the higher, generic EUR price to German shoppers, and the team assumed their region setup was wrong, then spent an afternoon re-creating the region price from scratch with no change in behavior.
Running the detection script found the exact variant, printed both the expected region price id and the id Medusa actually served, and the two did not match. That confirmed it was the resolver, not the data. They applied the documented workaround, adding an explicit currency_code condition to the region row, and the storefront started serving the correct discounted price the same day.
The brand that needed a higher price in one currency zone
A skincare brand priced a bestseller higher in a premium market to protect margin there, keeping the standard price everywhere else in the same currency. Customer support started getting messages about a price that "looked wrong" compared to what marketing had promised, and nobody could tell if it was a caching issue or a real bug.
The script's cross-check against the Store API showed the served price id always matching the plain currency row, never the region row, across every product with this setup. That ruled out caching entirely. The team fixed the affected prices with the workaround, re-ran the detection, and confirmed every flagged pair now resolved to the correct region id before closing the ticket.
After running this check, you have a precise list of every variant and region where the region price is being ignored, with the exact price ids Medusa expected versus what it served. Nothing gets auto-mutated. A human reviews the list, applies the documented workaround of adding an explicit currency_code condition to the region row, and the same detection script becomes the re-check that proves the fix actually worked before you consider the ticket closed.
FAQ
Why does Medusa ignore my region scoped price and charge the currency only price instead?
Medusa's Pricing Module tries to find a price whose rule set is an exact, complete match for the request context such as region_id and currency_code. When no price satisfies every rule at once, it falls back to the price matching the most rules, and ties or partial-match edge cases resolve toward the plain currency only row instead of the region scoped one. Community reports such as GitHub issue 13120 confirm calculated_price frequently returns the currency only default or even null once a variant has any region_id rule on one of its prices, even though the correct region_id and currency_code were passed in.
How do I check whether this bug is affecting my store?
Pull each variant's prices and price rules from the Admin API, group them by currency_code, and flag a variant when it has both a region scoped price for a region and a plain currency only price in the same currency. Then call the Store API for that product with region_id set and compare the returned calculated_price.id against the region scoped price's own id. If the served id matches the currency only row instead of the region scoped row, that variant and region pair is affected.
What is the safe way to fix a variant that is affected?
This is a price selection engine bug, not bad data, so do not delete or guess new prices. The documented workaround is to add an explicit currency_code condition alongside the existing region_id rule on the same Price row, since a price matching both rules outranks a currency only price. Make this change under a DRY_RUN flag, log the plan first, and only apply it once you have reviewed the affected list, then re-run the detection check to confirm calculated_price now returns the region scoped price's id.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #13120: calculated_price defined by region is not obtained. github.com/medusajs/medusa/issues/13120
- medusajs/medusa GitHub issue #10613: calculated_price context works only with price lists and does not take other prices into consideration. github.com/medusajs/medusa/issues/10613
- medusajs/medusa GitHub issue #1175: Price selection strategy, make region_id and currency_code both optional. github.com/medusajs/medusa/issues/1175
On the solution:
- Medusa Documentation: Prices Calculation in the Pricing Module. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
- Medusa Documentation: Pricing Concepts. docs.medusajs.com/resources/commerce-modules/pricing/concepts
- Medusa Documentation: Pricing Module. docs.medusajs.com/resources/commerce-modules/pricing
Stuck on a tricky one?
If you have a problem in Medusa 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 catch a mispriced region?
If this saved you from a confusing pricing ticket or a customer charged the wrong amount, 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