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.

Python and Node.js Medusa Admin API Safe by default (report first)
The short answer

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.

Region: EU currency_code: eur Variant price set has: usd, gbp missing: eur no eur match calculated_price comes back null Cart or line item fails
The region only ever asks for one currency. If the variant's price set has no Price row in that currency, Medusa returns null instead of guessing.

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:

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.

The key insight

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.

List regions id, currency_code List products variants and prices Build currency sets per region, per variant Currency missing? yes no, fully priced Skip, fine no gap here Report the gap
The job reports every gap by comparing two sets of currency codes. Filling a gap is a separate, deliberate step guarded by DRY_RUN.

Build it step by step

1

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.

setup (shell)
pip install requests

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

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, report only
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

apply.py
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"]
apply.js
// 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,
  });
}
6

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.

Run it safe

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_missing_region_prices.py
"""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-missing-region-prices.js
/**
 * 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.

test_region_prices.py
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
region-prices.test.js
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

New region launch

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.

Import gap

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.

What good looks like

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:

  1. Region and Currency code options missing on Price List. github.com/medusajs/medusa/issues/6670
  2. Unable to add an item to cart due to error: Variants with IDs do not have a price. github.com/medusajs/medusa/issues/11952
  3. Item does not have a price. github.com/medusajs/medusa/issues/11974

On the solution:

  1. Pricing Module, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/pricing
  2. Get Product Variant Prices using Query, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/product/guides/price
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa.js field notes