Reconciler Regions, pricing, and currency
Product has no price in a region
A shopper switches the store to a new region and the product page acts like the item does not exist, or the cart throws a does not have a price error at checkout. The variant is fine everywhere else. It just never got a price in that region's currency. Here is why Medusa lets that gap open up and a small script that finds every variant missing a price before a customer runs into it.
In Medusa v2 a region has exactly one currency_code, and a variant's price set holds an array of currency-scoped Price records. Nothing forces every variant to have a price in every currency your regions use, so run a small Python or Node.js script that lists every region's currency, lists every variant's prices, and diffs the two. Any region whose currency is missing from a variant's prices is a gap that will show up as a null price on the storefront. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa prices a variant per currency, not per region directly. A region just points at one currency code. When the storefront asks for a price, Medusa looks at the region's currency and searches the variant's price set for a Price row in that exact currency, plus any price list entries that might cover it.
If that search comes up empty, Medusa does not fall back to another currency and it does not guess. It returns calculated_price: null for that variant in that region. The storefront then has nothing to show, and if a shopper still gets it into a cart, the cart and line item workflows throw an error instead of quietly completing the sale.
Why it happens
Nothing in Medusa enforces that a variant's price set stays in sync with every region currency in the store. A few common ways stores end up with the gap:
- A new region is added for a new market, with a currency the catalog was never priced in, and no one back-fills prices for the existing variants.
- A variant is created after the regions were already configured, and whoever added it only entered a price in the store's original currency.
- An import or migration script sets prices per currency and simply forgot one, so a subset of variants is quietly incomplete.
- A price list was meant to cover the gap with a rule scoped to that region or currency, but the price list is expired, in draft, or scoped to the wrong thing, so it does not actually cover it.
This is a common source of confusion because everything looks correct in the admin list view, which usually shows the default currency's price. The gap only shows up when a shopper actually switches region, and by then it looks like a broken product rather than a missing row. See the citations at the end for the exact issues and docs.
A region is really just a currency code plus some settings. Medusa never resolves a price across currencies, it only matches exactly. So the fix is not a Medusa bug to patch, it is a reconciliation problem: build the full set of currencies your regions use, build the full set of currencies each variant is priced in, and diff them. Anything in the first set but not the second is a gap.
The fix, as a flow
We do not touch checkout or pricing logic. We add a job that reads every region's currency, reads every variant's prices, and reports the exact {variant, region} pairs that would come back with no price. Filling the gap is left as a deliberate, human-approved step, because picking an amount for a new currency is a business decision.
Build it step by step
Get an Admin API session
Medusa Admin auth is a JWT, not a long-lived static token. Exchange an admin email and password for a token at POST /auth/user/emailpass, then send it back as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL, email, and password in environment variables.
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, report only
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, report only
List every region and its currency
A region's currency is what the storefront asks a variant to be priced in. Pull the id, name, and currency_code for every region so we know the full set of currencies the catalog needs to cover.
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", "limit": 1000},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
await sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function listRegions() {
const { regions } = await sdk.admin.region.list({
fields: "id,name,currency_code",
limit: 1000,
});
return regions;
}
List products with variants and prices
Expand variants and variants.prices with the fields query param so each variant comes back with its full price array, then page through with limit and offset until the count is covered.
def list_products(token):
offset = 0
limit = 100
while True:
r = requests.get(
f"{BASE}/admin/products",
params={
"fields": "id,title,status,*variants,*variants.prices",
"limit": limit,
"offset": offset,
},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
async function* listProducts() {
const limit = 100;
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,title,status,*variants,*variants.prices",
limit,
offset,
});
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
Decide, with one pure function
Keep the diff in its own function that takes plain variant and region data and returns the list of gaps. A pure function like this is easy to read and easy to test, which we do later. For each variant we build the set of currency codes it has a price for, then for each region we check whether that region's currency is in the set. If not, that pair is a gap.
def find_missing_region_prices(variants, regions):
gaps = []
for variant in variants:
priced_currencies = {
(p.get("currency_code") or "").lower()
for p in (variant.get("prices") or [])
}
for region in regions:
region_currency = (region.get("currency_code") or "").lower()
if region_currency not in priced_currencies:
gaps.append({
"variant_id": variant.get("id"),
"sku": variant.get("sku"),
"region_id": region.get("id"),
"region_name": region.get("name"),
"missing_currency_code": region.get("currency_code"),
})
return gaps
export function findMissingRegionPrices(variants, regions) {
const gaps = [];
for (const variant of variants) {
const pricedCurrencies = new Set(
(variant.prices || []).map((p) => (p.currency_code || "").toLowerCase())
);
for (const region of regions) {
const regionCurrency = (region.currency_code || "").toLowerCase();
if (!pricedCurrencies.has(regionCurrency)) {
gaps.push({
variant_id: variant.id,
sku: variant.sku,
region_id: region.id,
region_name: region.name,
missing_currency_code: region.currency_code,
});
}
}
}
return gaps;
}
Report the gaps, and optionally fill them under DRY_RUN
By default the script only reports gaps, since choosing an amount for a missing currency is a pricing decision, not something a script should guess. If you do want an auto-fill that mirrors an existing reference currency at a configured FX rate, the write is a variant update: POST /admin/products/{"{product_id}"}/variants/{"{variant_id}"} with a prices array that includes the existing prices plus the new one, since the route upserts the whole price set. Under DRY_RUN=true only log the computed tuples and skip the POST.
def fill_missing_price(token, product_id, variant_id, existing_prices, currency_code, amount):
"""Only called when DRY_RUN is false and a human supplied the amount."""
new_prices = existing_prices + [{"currency_code": currency_code, "amount": amount}]
r = requests.post(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
json={"prices": new_prices},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
// Only called when DRY_RUN is false and a human supplied the amount.
async function fillMissingPrice(productId, variantId, existingPrices, currencyCode, amount) {
const newPrices = [...existingPrices, { currency_code: currencyCode, amount }];
return sdk.admin.product.updateVariant(productId, variantId, {
prices: newPrices,
});
}
Wire it together with a dry run guard
The run loop logs in, pulls regions and products, runs the pure diff, and prints every gap it finds. Nothing is written unless DRY_RUN is explicitly set to false and you have wired in an approved amount and FX source for each currency. Run it on a schedule, or right after adding a new region, so a gap gets caught before a customer does.
Leave DRY_RUN=true for reporting. Only turn it off once a human has decided the amount for each missing currency, whether that is a manual price or a computed FX conversion. The script should never guess an amount on its own.
The full code
Here is the complete script in one file for each language. It logs in, reads regions and products from the environment's Medusa backend, runs the pure diff, and prints every gap. The write path is present but only runs when DRY_RUN is false and an approved amount is supplied.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa v2 variants that have no price in a region's currency.
Every region has one currency_code. A variant is only purchasable in that
region if its price set has a Price row in that currency. This script lists
every region and every product's variants, then reports every {variant,
region} pair that is missing a price. It only reports by default. Filling a
gap is a separate, human-approved step behind DRY_RUN.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_missing_region_prices")
BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
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", "limit": 1000},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["regions"]
def list_products(token):
offset = 0
limit = 100
while True:
r = requests.get(
f"{BASE}/admin/products",
params={
"fields": "id,title,status,*variants,*variants.prices",
"limit": limit,
"offset": offset,
},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
for product in body["products"]:
yield product
offset += limit
if offset >= body["count"]:
return
def find_missing_region_prices(variants, regions):
gaps = []
for variant in variants:
priced_currencies = {
(p.get("currency_code") or "").lower()
for p in (variant.get("prices") or [])
}
for region in regions:
region_currency = (region.get("currency_code") or "").lower()
if region_currency not in priced_currencies:
gaps.append({
"variant_id": variant.get("id"),
"sku": variant.get("sku"),
"region_id": region.get("id"),
"region_name": region.get("name"),
"missing_currency_code": region.get("currency_code"),
})
return gaps
def fill_missing_price(token, product_id, variant_id, existing_prices, currency_code, amount):
"""Only called when DRY_RUN is false and a human supplied the amount."""
new_prices = existing_prices + [{"currency_code": currency_code, "amount": amount}]
r = requests.post(
f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
json={"prices": new_prices},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["variant"]
def run():
token = login()
regions = list_regions(token)
total_gaps = 0
for product in list_products(token):
variants = product.get("variants") or []
gaps = find_missing_region_prices(variants, regions)
for gap in gaps:
log.warning(
"Product %s variant %s (%s) has no price for region %s (%s).",
product.get("title"), gap["variant_id"], gap["sku"],
gap["region_name"], gap["missing_currency_code"],
)
total_gaps += 1
log.info("Done. %d gap(s) %s.", total_gaps, "found" if DRY_RUN else "found (dry run off, no auto-fill wired in)")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 variants that have no price in a region's currency.
*
* Every region has one currency_code. A variant is only purchasable in that
* region if its price set has a Price row in that currency. This script lists
* every region and every product's variants, then reports every {variant,
* region} pair that is missing a price. It only reports by default. Filling a
* gap is a separate, human-approved step behind DRY_RUN.
*
* Guide: https://www.allanninal.dev/medusa/product-has-no-price-in-a-region/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findMissingRegionPrices(variants, regions) {
const gaps = [];
for (const variant of variants) {
const pricedCurrencies = new Set(
(variant.prices || []).map((p) => (p.currency_code || "").toLowerCase())
);
for (const region of regions) {
const regionCurrency = (region.currency_code || "").toLowerCase();
if (!pricedCurrencies.has(regionCurrency)) {
gaps.push({
variant_id: variant.id,
sku: variant.sku,
region_id: region.id,
region_name: region.name,
missing_currency_code: region.currency_code,
});
}
}
}
return gaps;
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listRegions(sdk) {
const { regions } = await sdk.admin.region.list({
fields: "id,name,currency_code",
limit: 1000,
});
return regions;
}
async function* listProducts(sdk) {
const limit = 100;
let offset = 0;
while (true) {
const body = await sdk.admin.product.list({
fields: "id,title,status,*variants,*variants.prices",
limit,
offset,
});
for (const product of body.products) yield product;
offset += limit;
if (offset >= body.count) return;
}
}
// Only called when DRY_RUN is false and a human supplied the amount.
async function fillMissingPrice(sdk, productId, variantId, existingPrices, currencyCode, amount) {
const newPrices = [...existingPrices, { currency_code: currencyCode, amount }];
return sdk.admin.product.updateVariant(productId, variantId, { prices: newPrices });
}
export async function run() {
const sdk = await getSdk();
const regions = await listRegions(sdk);
let totalGaps = 0;
for await (const product of listProducts(sdk)) {
const variants = product.variants || [];
const gaps = findMissingRegionPrices(variants, regions);
for (const gap of gaps) {
console.warn(
`Product ${product.title} variant ${gap.variant_id} (${gap.sku}) has no price for region ${gap.region_name} (${gap.missing_currency_code}).`
);
totalGaps++;
}
}
console.log(`Done. ${totalGaps} gap(s) ${DRY_RUN ? "found" : "found (dry run off, no auto-fill wired in)"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The diff is the part most worth testing, because it decides which gaps get reported. Because find_missing_region_prices is pure, the test needs no network and no Medusa backend. It just feeds in plain arrays of variants and regions and checks the gaps that come back.
from find_missing_region_prices import find_missing_region_prices
def variant(**over):
base = {"id": "variant_1", "sku": "SKU-1", "prices": [{"currency_code": "usd"}, {"currency_code": "gbp"}]}
base.update(over)
return base
def region(**over):
base = {"id": "reg_1", "name": "United States", "currency_code": "usd"}
base.update(over)
return base
def test_no_gaps_when_all_currencies_covered():
regions = [region(), region(id="reg_2", name="United Kingdom", currency_code="gbp")]
assert find_missing_region_prices([variant()], regions) == []
def test_gap_when_region_currency_missing():
regions = [region(id="reg_3", name="Eurozone", currency_code="eur")]
gaps = find_missing_region_prices([variant()], regions)
assert len(gaps) == 1
assert gaps[0]["region_name"] == "Eurozone"
assert gaps[0]["missing_currency_code"] == "eur"
assert gaps[0]["variant_id"] == "variant_1"
def test_gap_when_variant_has_no_prices_at_all():
gaps = find_missing_region_prices([variant(prices=[])], [region()])
assert len(gaps) == 1
def test_currency_match_is_case_insensitive():
regions = [region(currency_code="USD")]
assert find_missing_region_prices([variant()], regions) == []
def test_multiple_variants_and_regions_each_checked():
variants = [variant(), variant(id="variant_2", sku="SKU-2", prices=[])]
regions = [region(), region(id="reg_2", name="Eurozone", currency_code="eur")]
gaps = find_missing_region_prices(variants, regions)
# variant_1 is missing eur only, variant_2 is missing both usd and eur
assert len(gaps) == 3
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissingRegionPrices } from "./find-missing-region-prices.js";
const variant = (over = {}) => ({
id: "variant_1",
sku: "SKU-1",
prices: [{ currency_code: "usd" }, { currency_code: "gbp" }],
...over,
});
const region = (over = {}) => ({
id: "reg_1",
name: "United States",
currency_code: "usd",
...over,
});
test("no gaps when all currencies covered", () => {
const regions = [region(), region({ id: "reg_2", name: "United Kingdom", currency_code: "gbp" })];
assert.deepEqual(findMissingRegionPrices([variant()], regions), []);
});
test("gap when region currency missing", () => {
const regions = [region({ id: "reg_3", name: "Eurozone", currency_code: "eur" })];
const gaps = findMissingRegionPrices([variant()], regions);
assert.equal(gaps.length, 1);
assert.equal(gaps[0].region_name, "Eurozone");
assert.equal(gaps[0].missing_currency_code, "eur");
assert.equal(gaps[0].variant_id, "variant_1");
});
test("gap when variant has no prices at all", () => {
const gaps = findMissingRegionPrices([variant({ prices: [] })], [region()]);
assert.equal(gaps.length, 1);
});
test("currency match is case insensitive", () => {
const regions = [region({ currency_code: "USD" })];
assert.deepEqual(findMissingRegionPrices([variant()], regions), []);
});
test("multiple variants and regions each checked", () => {
const variants = [variant(), variant({ id: "variant_2", sku: "SKU-2", prices: [] })];
const regions = [region(), region({ id: "reg_2", name: "Eurozone", currency_code: "eur" })];
const gaps = findMissingRegionPrices(variants, regions);
// variant_1 is missing eur only, variant_2 is missing both usd and eur
assert.equal(gaps.length, 3);
});
Case studies
The EU region went live with a half priced catalog
A store added a Eurozone region to expand into Europe, and the team assumed the base USD prices would just convert automatically. Within a day, support tickets came in from shoppers whose carts said several products had no price, even though the products worked fine in the US region.
Running the reconciliation script against the catalog surfaced a few hundred variants missing a eur price, all from a product line added months earlier without eur ever being set up. The team priced that batch properly and re-ran the script to confirm zero gaps before advertising the new region.
A bulk import silently skipped one currency
A migration script imported thousands of variants from a legacy platform and wrote prices for usd and gbp, but the import spec never mentioned the store's third region, aud, because it was added after the import was written. Nobody noticed until an Australian customer reported that random products would not add to cart.
The team scheduled the script to run nightly against the admin API. It now catches any new gap, whether from a bad import, a new variant, or a new region, well before it reaches a live shopper.
After this runs on a schedule or right after adding a region, every gap between region currencies and variant prices is a warning in your logs, not a silent null on a customer's screen. Filling a gap stays a human decision about the right amount, and the script's only job is making sure nothing gets missed.
FAQ
Why does a Medusa product have no price in a region?
Every region in Medusa v2 has exactly one currency code, and a variant is only purchasable in that region if its price set holds a Price record in that same currency. Nothing forces every variant to carry a price for every currency a store's regions use, so a new region or a variant added after regions were set up can end up with no matching price.
What does calculated_price null mean in Medusa?
When the storefront or cart asks Medusa to price a variant for a given region and currency, Medusa looks for a Price row that matches that currency, plus any price list entries. If none exists, calculated_price comes back null and the variant is treated as unpurchasable there, which is why cart and line item workflows throw a does not have a price error instead of silently using another currency.
Should a script automatically add the missing price?
Not by default. Picking an amount for a new currency is a pricing decision, not a technical one, so the safe default is to detect and report every gap. An auto-fill that mirrors an existing currency at a fixed FX rate can be wired in behind a DRY_RUN guard, but it should only write once a human has approved the rate and the amounts it will use.
Related field notes
Citations
On the problem:
- Region and Currency code options missing on Price List. github.com/medusajs/medusa/issues/6670
- Unable to add an item to cart due to error: Variants with IDs do not have a price. github.com/medusajs/medusa/issues/11952
- Item does not have a price. github.com/medusajs/medusa/issues/11974
On the solution:
- Pricing Module, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/pricing
- Get Product Variant Prices using Query, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/product/guides/price
- Region Module, Medusa Development Resources. docs.medusajs.com/resources/commerce-modules/region
Stuck on a tricky one?
If you have a problem in Medusa pricing, regions, 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 find a gap in your catalog?
If this caught a missing region price before a customer did, 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