Skip to content

Diagnostic Pricing & Promotions

A price list suppresses all default variant prices once active

You built a price list for one customer group, or one short campaign window. The moment it goes active, shoppers who were never supposed to see it start getting the price list's number instead of the regular price, and it does not matter whether the default price was actually lower. Here is why Medusa's pricing engine stops looking at the default price the instant any price list matches, and a small script that finds every variant this is quietly happening to.

Python and Node.js Medusa Admin API Safe by default (report only)
A calculator on a table
Photo by Behnam Norouzi on Unsplash
The short answer

Medusa v2's pricing module resolves calculated_price by first checking whether any price list price matches the given context, such as region, currency, or a customer group rule. If a matching price list price set exists at all, the price-selection strategy restricts its candidate pool to price list scoped prices and never falls back to compare against the variant's default, no price list prices, even when the default is cheaper or the price list's own rules do not actually apply to the current shopper. This is a known bug tracked as medusajs/medusa#10613. Run a small Python or Node.js script that lists active price lists, resolves the variants and default prices behind them, requests calculated_price in context, and flags every variant and currency where the default price was wrongly suppressed. Full code, tests, and a dry run guard are below.

The problem in plain words

A price list in Medusa v2 is meant to be an override. It usually carries rules, like a specific customer_group_id, and a start_date and end_date, so that only the intended shoppers see the special price, and only for as long as the campaign runs. Everyone else should keep seeing the variant's ordinary default price.

That is not what actually happens once the price list is active. When Medusa resolves calculated_price for a variant, it first checks whether any price list price matches the request context at all. As soon as one does, the pricing strategy narrows its whole search to price list prices only. It stops comparing against the default price entirely, it does not check whether the default was lower, and in some paths it does not even confirm the price list's own rules line up with the shopper making the request. The result is a price list meant for a subset of customers ends up replacing the default price for everyone, and a promotion meant to only lower a price can end up returning a higher one instead.

calculated_price requested for a variant Price list price matches the context, even loosely default is never checked Candidate pool narrowed to price list prices only Wrong price served The default price is cheaper, or the rules do not match this shopper, but neither is ever compared.
Once a matching price list price exists at all, Medusa stops considering the variant's default price, so a stale or wrongly scoped price gets served instead.

Why it happens

The underlying cause is in how the pricing module picks a candidate price set, not in any one price list you configured. A few common ways this surfaces in a real store:

This is documented as a known Medusa core bug, tracked at medusajs/medusa#10613, with related reports at medusajs/medusa#9625 and medusajs/medusa#10490. It is not a data mistake in any single price list. It is the price-selection strategy itself skipping the fallback comparison whenever a price list candidate exists.

The key insight

This is a Medusa core pricing-engine bug, not a per-record data error, so it is not safe to fix by mutating store data. Deactivating a price list can be the wrong move too, since it might be a legitimate promotion the merchant still wants live for its correctly scoped audience. The safe move is to detect and report every affected variant, currency, and price list combination, and let a human decide, adding an explicit price row on the list itself where that is the right workaround.

The fix, as a flow

We do not patch the pricing module or touch any price list automatically. We list active price lists, resolve the variants and default prices behind each one, request calculated_price for the relevant region and currency, and compare what came back against the default. Only when the comparison shows the default was wrongly suppressed do we report it, with the exact delta, for a merchant to review.

List price lists status active Resolve variants and default prices Request calculated_price with region and currency Wrongly suppressed? yes no, skip Correctly priced Report with delta for merchant review
The script only reports variants where the default price was wrongly suppressed, with the exact amount difference. Nothing is changed automatically.

Build it step by step

1

Get an Admin API token and set up your environment

Exchange your admin email and password for a JWT at POST /auth/user/emailpass and send it back as Authorization: Bearer <token> on every admin call. Keep the backend URL, the admin credentials, the region and currency you want to check, and a dry run flag in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MEDUSA_REGION_ID="reg_01..."
export MEDUSA_CURRENCY_CODE="usd"
export DRY_RUN="true"   # report only, this script never writes prices unless told to
setup (shell)
// 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_REGION_ID="reg_01..."
export MEDUSA_CURRENCY_CODE="usd"
export DRY_RUN="true"   // report only, this script never writes prices unless told to
2

List active price lists and resolve their variants

Ask for price lists with status[]=active, expanding rules and prices. For each one, fetch the underlying products with their variants and prices expanded, so we can read both the price list's own price rows and each variant's default rows, the ones with no price_list_id, for the same currency.

step2.py
import os, requests

BASE = os.environ["MEDUSA_BACKEND_URL"]

def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": os.environ["MEDUSA_ADMIN_EMAIL"], "password": os.environ["MEDUSA_ADMIN_PASSWORD"]},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def active_price_lists(token):
    fields = "id,title,status,rules,starts_at,ends_at,prices.amount,prices.currency_code,prices.price_list_id"
    offset, out = 0, []
    while True:
        r = requests.get(
            f"{BASE}/admin/price-lists",
            params={"status[]": "active", "fields": fields, "offset": offset, "limit": 50},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["price_lists"])
        offset += body["limit"]
        if offset >= body["count"]:
            return out

def price_list_products(token, price_list_id):
    fields = "id,*variants,variants.prices.amount,variants.prices.currency_code,variants.prices.price_list_id"
    r = requests.get(
        f"{BASE}/admin/price-lists/{price_list_id}/products",
        params={"fields": fields},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["products"]
step2.js
const BASE = process.env.MEDUSA_BACKEND_URL;

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

async function activePriceLists(token) {
  const fields = "id,title,status,rules,starts_at,ends_at,prices.amount,prices.currency_code,prices.price_list_id";
  let offset = 0;
  const out = [];
  while (true) {
    const res = await fetch(
      `${BASE}/admin/price-lists?status[]=active&fields=${encodeURIComponent(fields)}&offset=${offset}&limit=50`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.price_lists);
    offset += body.limit;
    if (offset >= body.count) return out;
  }
}

async function priceListProducts(token, priceListId) {
  const fields = "id,*variants,variants.prices.amount,variants.prices.currency_code,variants.prices.price_list_id";
  const res = await fetch(
    `${BASE}/admin/price-lists/${priceListId}/products?fields=${encodeURIComponent(fields)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).products;
}
3

Request calculated_price in the shopper's context

For each variant, call the product endpoint with fields=id,*variants.calculated_price and the same region_id and currency_code a real storefront request would use. Read back calculated_amount, is_calculated_price_price_list, and price_list_id from the response.

step3.py
def calculated_price(token, product_id, region_id, currency_code):
    fields = "id,*variants.calculated_price"
    r = requests.get(
        f"{BASE}/admin/products/{product_id}",
        params={"fields": fields, "region_id": region_id, "currency_code": currency_code},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]["variants"]

def default_amount_for_currency(variant, currency_code):
    for price in variant.get("prices") or []:
        if price.get("currency_code") == currency_code and not price.get("price_list_id"):
            return price["amount"]
    return None
step3.js
async function calculatedPrice(token, productId, regionId, currencyCode) {
  const fields = "id,*variants.calculated_price";
  const res = await fetch(
    `${BASE}/admin/products/${productId}?fields=${encodeURIComponent(fields)}®ion_id=${regionId}¤cy_code=${currencyCode}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).product.variants;
}

function defaultAmountForCurrency(variant, currencyCode) {
  for (const price of variant.prices || []) {
    if (price.currency_code === currencyCode && !price.price_list_id) return price.amount;
  }
  return null;
}
4

Decide, with one pure function

Keep the decision in its own function with no I/O. It takes the calculated price result, the price list's rules, the request context, and the resolved default amount, and returns whether the default was wrongly suppressed and why. If the price list's rules do not match the requesting context, that alone is a red flag. Otherwise, a price list amount that is higher than the default is also wrong, since a fallback should have won.

decide.py
def rules_match(price_list_rules, request_context):
    for rule_key, allowed_values in (price_list_rules or {}).items():
        if rule_key == "customer_group_id":
            requested = set(request_context.get("customer_group_ids") or [])
            if not requested.intersection(allowed_values):
                return False
    return True


def is_default_price_wrongly_suppressed(
    calculated_amount,
    is_calculated_price_from_price_list,
    price_list_rules,
    request_context,
    default_amount_for_currency,
):
    if not is_calculated_price_from_price_list:
        return {"suppressed": False, "reason": "none"}

    if default_amount_for_currency is None:
        return {"suppressed": False, "reason": "none"}

    if not rules_match(price_list_rules, request_context):
        return {"suppressed": True, "reason": "rules_mismatch"}

    if calculated_amount > default_amount_for_currency:
        return {"suppressed": True, "reason": "higher_than_default"}

    return {"suppressed": False, "reason": "none"}
decide.js
export function rulesMatch(priceListRules, requestContext) {
  for (const [ruleKey, allowedValues] of Object.entries(priceListRules || {})) {
    if (ruleKey === "customer_group_id") {
      const requested = new Set(requestContext.customerGroupIds || []);
      if (!allowedValues.some((v) => requested.has(v))) return false;
    }
  }
  return true;
}

export function isDefaultPriceWronglySuppressed(input) {
  const { calculatedAmount, isCalculatedPriceFromPriceList, priceListRules, requestContext, defaultAmountForCurrency } = input;

  if (!isCalculatedPriceFromPriceList) return { suppressed: false, reason: "none" };
  if (defaultAmountForCurrency == null) return { suppressed: false, reason: "none" };

  if (!rulesMatch(priceListRules, requestContext)) {
    return { suppressed: true, reason: "rules_mismatch" };
  }

  if (calculatedAmount > defaultAmountForCurrency) {
    return { suppressed: true, reason: "higher_than_default" };
  }

  return { suppressed: false, reason: "none" };
}
5

Report, never mutate automatically

When a variant is flagged, log the price list id, the variant id, the currency, the calculated amount, the default amount, and the reason. This is a report, not a repair. Deleting or deactivating a price list is destructive and can remove a promotion the merchant still wants, so that action, if ever taken, must stay behind an explicit, separate confirmation.

report.py
def report_line(price_list_id, variant_id, currency_code, decision, calculated_amount, default_amount):
    return (
        f"price_list={price_list_id} variant={variant_id} currency={currency_code} "
        f"reason={decision['reason']} calculated={calculated_amount} default={default_amount}"
    )
report.js
function reportLine(priceListId, variantId, currencyCode, decision, calculatedAmount, defaultAmount) {
  return `price_list=${priceListId} variant=${variantId} currency=${currencyCode} ` +
    `reason=${decision.reason} calculated=${calculatedAmount} default=${defaultAmount}`;
}
6

Wire it together with a dry run guard

The loop ties every piece together. This script defaults to report only, since the correct repair, an explicit price row on the price list, is a merchant decision about intent. Leave DRY_RUN on so it only prints what it finds. Run it after publishing any new price list, and again on a schedule to catch drift.

Run it safe

Always start with DRY_RUN=true. This script never edits or deactivates a price list on its own, since that can be a legitimate promotion still meant for its correctly scoped audience. It only reports the affected variant, currency, and price list combination for a human to review.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks active price lists, their variants, and default prices, applies the pure decision function, and reports every variant where the default price was wrongly suppressed.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
find_suppressed_default_price.py
"""Find Medusa v2 variants where an active price list is wrongly suppressing
the default variant price.

Medusa's pricing module resolves calculated_price by first checking whether
any price list price matches the given context. Once a matching price list
price set exists at all, the price-selection strategy never falls back to
compare against the variant's default price, even when the price list rules
do not match the current shopper or the default is actually cheaper. This is
a known core bug (medusajs/medusa#10613). This script only reports affected
variants. It never edits or deactivates a price list on its own.

Guide: https://www.allanninal.dev/medusa/price-list-suppresses-default-price/
"""
import os
import logging
import requests

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

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
REGION_ID = os.environ.get("MEDUSA_REGION_ID", "")
CURRENCY_CODE = os.environ.get("MEDUSA_CURRENCY_CODE", "usd")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PRICE_LIST_FIELDS = "id,title,status,rules,starts_at,ends_at,prices.amount,prices.currency_code,prices.price_list_id"
PRODUCT_FIELDS = "id,*variants,variants.prices.amount,variants.prices.currency_code,variants.prices.price_list_id"


def rules_match(price_list_rules, request_context):
    """Pure. price_list_rules is a plain dict of rule_key -> [allowed values].
    request_context carries customer_group_ids among other fields."""
    for rule_key, allowed_values in (price_list_rules or {}).items():
        if rule_key == "customer_group_id":
            requested = set(request_context.get("customer_group_ids") or [])
            if not requested.intersection(allowed_values):
                return False
    return True


def is_default_price_wrongly_suppressed(
    calculated_amount,
    is_calculated_price_from_price_list,
    price_list_rules,
    request_context,
    default_amount_for_currency,
):
    """Pure decision logic. No I/O. Returns {"suppressed": bool, "reason": str}."""
    if not is_calculated_price_from_price_list:
        return {"suppressed": False, "reason": "none"}

    if default_amount_for_currency is None:
        return {"suppressed": False, "reason": "none"}

    if not rules_match(price_list_rules, request_context):
        return {"suppressed": True, "reason": "rules_mismatch"}

    if calculated_amount > default_amount_for_currency:
        return {"suppressed": True, "reason": "higher_than_default"}

    return {"suppressed": False, "reason": "none"}


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 active_price_lists(token):
    offset, out = 0, []
    while True:
        r = requests.get(
            f"{BASE}/admin/price-lists",
            params={"status[]": "active", "fields": PRICE_LIST_FIELDS, "offset": offset, "limit": 50},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["price_lists"])
        offset += body["limit"]
        if offset >= body["count"]:
            return out


def price_list_products(token, price_list_id):
    r = requests.get(
        f"{BASE}/admin/price-lists/{price_list_id}/products",
        params={"fields": PRODUCT_FIELDS},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["products"]


def calculated_price_for_product(token, product_id, region_id, currency_code):
    r = requests.get(
        f"{BASE}/admin/products/{product_id}",
        params={"fields": "id,*variants.calculated_price", "region_id": region_id, "currency_code": currency_code},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["product"]["variants"]


def default_amount_for_currency(variant, currency_code):
    for price in variant.get("prices") or []:
        if price.get("currency_code") == currency_code and not price.get("price_list_id"):
            return price["amount"]
    return None


def report_line(price_list_id, variant_id, currency_code, decision, calculated_amount, default_amount):
    return (
        f"price_list={price_list_id} variant={variant_id} currency={currency_code} "
        f"reason={decision['reason']} calculated={calculated_amount} default={default_amount}"
    )


def run():
    token = login()
    request_context = {"region_id": REGION_ID, "currency_code": CURRENCY_CODE, "customer_group_ids": []}
    flagged = 0

    for price_list in active_price_lists(token):
        rules = price_list.get("rules") or {}
        for product in price_list_products(token, price_list["id"]):
            calc_variants = {v["id"]: v for v in calculated_price_for_product(token, product["id"], REGION_ID, CURRENCY_CODE)}

            for variant in product.get("variants") or []:
                calc = calc_variants.get(variant["id"], {}).get("calculated_price") or {}
                default_amount = default_amount_for_currency(variant, CURRENCY_CODE)

                decision = is_default_price_wrongly_suppressed(
                    calculated_amount=calc.get("calculated_amount"),
                    is_calculated_price_from_price_list=bool(calc.get("is_calculated_price_price_list")),
                    price_list_rules=rules,
                    request_context=request_context,
                    default_amount_for_currency=default_amount,
                )

                if not decision["suppressed"]:
                    continue

                log.warning(
                    report_line(price_list["id"], variant["id"], CURRENCY_CODE, decision, calc.get("calculated_amount"), default_amount)
                )
                flagged += 1

    log.info("Done. %d variant/price list combination(s) flagged for review. Dry run: %s", flagged, DRY_RUN)


if __name__ == "__main__":
    run()
find-suppressed-default-price.js
/**
 * Find Medusa v2 variants where an active price list is wrongly suppressing
 * the default variant price.
 *
 * Medusa's pricing module resolves calculated_price by first checking
 * whether any price list price matches the given context. Once a matching
 * price list price set exists at all, the price-selection strategy never
 * falls back to compare against the variant's default price, even when the
 * price list rules do not match the current shopper or the default is
 * actually cheaper. This is a known core bug (medusajs/medusa#10613). This
 * script only reports affected variants. It never edits or deactivates a
 * price list on its own.
 *
 * Guide: https://www.allanninal.dev/medusa/price-list-suppresses-default-price/
 */
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 REGION_ID = process.env.MEDUSA_REGION_ID || "";
const CURRENCY_CODE = process.env.MEDUSA_CURRENCY_CODE || "usd";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PRICE_LIST_FIELDS = "id,title,status,rules,starts_at,ends_at,prices.amount,prices.currency_code,prices.price_list_id";
const PRODUCT_FIELDS = "id,*variants,variants.prices.amount,variants.prices.currency_code,variants.prices.price_list_id";

// Pure. priceListRules is a plain object of ruleKey -> [allowed values].
// requestContext carries customerGroupIds among other fields.
export function rulesMatch(priceListRules, requestContext) {
  for (const [ruleKey, allowedValues] of Object.entries(priceListRules || {})) {
    if (ruleKey === "customer_group_id") {
      const requested = new Set(requestContext.customerGroupIds || []);
      if (!allowedValues.some((v) => requested.has(v))) return false;
    }
  }
  return true;
}

// Pure decision logic. No I/O. Returns { suppressed, reason }.
export function isDefaultPriceWronglySuppressed(input) {
  const { calculatedAmount, isCalculatedPriceFromPriceList, priceListRules, requestContext, defaultAmountForCurrency } = input;

  if (!isCalculatedPriceFromPriceList) return { suppressed: false, reason: "none" };
  if (defaultAmountForCurrency == null) return { suppressed: false, reason: "none" };

  if (!rulesMatch(priceListRules, requestContext)) {
    return { suppressed: true, reason: "rules_mismatch" };
  }

  if (calculatedAmount > defaultAmountForCurrency) {
    return { suppressed: true, reason: "higher_than_default" };
  }

  return { suppressed: false, reason: "none" };
}

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}`);
  return (await res.json()).token;
}

async function activePriceLists(token) {
  let offset = 0;
  const out = [];
  while (true) {
    const res = await fetch(
      `${BASE}/admin/price-lists?status[]=active&fields=${encodeURIComponent(PRICE_LIST_FIELDS)}&offset=${offset}&limit=50`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    out.push(...body.price_lists);
    offset += body.limit;
    if (offset >= body.count) return out;
  }
}

async function priceListProducts(token, priceListId) {
  const res = await fetch(
    `${BASE}/admin/price-lists/${priceListId}/products?fields=${encodeURIComponent(PRODUCT_FIELDS)}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).products;
}

async function calculatedPriceForProduct(token, productId, regionId, currencyCode) {
  const res = await fetch(
    `${BASE}/admin/products/${productId}?fields=${encodeURIComponent("id,*variants.calculated_price")}®ion_id=${regionId}¤cy_code=${currencyCode}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).product.variants;
}

function defaultAmountForCurrency(variant, currencyCode) {
  for (const price of variant.prices || []) {
    if (price.currency_code === currencyCode && !price.price_list_id) return price.amount;
  }
  return null;
}

function reportLine(priceListId, variantId, currencyCode, decision, calculatedAmount, defaultAmount) {
  return `price_list=${priceListId} variant=${variantId} currency=${currencyCode} ` +
    `reason=${decision.reason} calculated=${calculatedAmount} default=${defaultAmount}`;
}

export async function run() {
  const token = await login();
  const requestContext = { regionId: REGION_ID, currencyCode: CURRENCY_CODE, customerGroupIds: [] };
  let flagged = 0;

  for (const priceList of await activePriceLists(token)) {
    const rules = priceList.rules || {};
    for (const product of await priceListProducts(token, priceList.id)) {
      const calcVariants = new Map(
        (await calculatedPriceForProduct(token, product.id, REGION_ID, CURRENCY_CODE)).map((v) => [v.id, v])
      );

      for (const variant of product.variants || []) {
        const calc = (calcVariants.get(variant.id) || {}).calculated_price || {};
        const defaultAmount = defaultAmountForCurrency(variant, CURRENCY_CODE);

        const decision = isDefaultPriceWronglySuppressed({
          calculatedAmount: calc.calculated_amount,
          isCalculatedPriceFromPriceList: Boolean(calc.is_calculated_price_price_list),
          priceListRules: rules,
          requestContext,
          defaultAmountForCurrency: defaultAmount,
        });

        if (!decision.suppressed) continue;

        console.warn(reportLine(priceList.id, variant.id, CURRENCY_CODE, decision, calc.calculated_amount, defaultAmount));
        flagged++;
      }
    }
  }

  console.log(`Done. ${flagged} variant/price list combination(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 decision rule is the part most worth testing, because it decides whether a variant gets flagged as wrongly priced. Because is_default_price_wrongly_suppressed is pure, the test needs no network and no live Medusa store. It just feeds in plain values and checks the answer.

test_price_suppression.py
from find_suppressed_default_price import is_default_price_wrongly_suppressed, rules_match


def test_not_suppressed_when_not_from_price_list():
    result = is_default_price_wrongly_suppressed(
        calculated_amount=1000,
        is_calculated_price_from_price_list=False,
        price_list_rules={},
        request_context={"customer_group_ids": []},
        default_amount_for_currency=1200,
    )
    assert result["suppressed"] is False
    assert result["reason"] == "none"


def test_not_suppressed_when_no_default_to_compare():
    result = is_default_price_wrongly_suppressed(
        calculated_amount=1000,
        is_calculated_price_from_price_list=True,
        price_list_rules={},
        request_context={"customer_group_ids": []},
        default_amount_for_currency=None,
    )
    assert result["suppressed"] is False


def test_suppressed_when_rules_do_not_match_context():
    result = is_default_price_wrongly_suppressed(
        calculated_amount=900,
        is_calculated_price_from_price_list=True,
        price_list_rules={"customer_group_id": ["cusgrp_vip"]},
        request_context={"customer_group_ids": ["cusgrp_general"]},
        default_amount_for_currency=1200,
    )
    assert result["suppressed"] is True
    assert result["reason"] == "rules_mismatch"


def test_suppressed_when_price_list_amount_higher_than_default():
    result = is_default_price_wrongly_suppressed(
        calculated_amount=1500,
        is_calculated_price_from_price_list=True,
        price_list_rules={},
        request_context={"customer_group_ids": []},
        default_amount_for_currency=1200,
    )
    assert result["suppressed"] is True
    assert result["reason"] == "higher_than_default"


def test_not_suppressed_when_rules_match_and_price_is_lower():
    result = is_default_price_wrongly_suppressed(
        calculated_amount=900,
        is_calculated_price_from_price_list=True,
        price_list_rules={"customer_group_id": ["cusgrp_vip"]},
        request_context={"customer_group_ids": ["cusgrp_vip"]},
        default_amount_for_currency=1200,
    )
    assert result["suppressed"] is False
    assert result["reason"] == "none"


def test_rules_match_with_no_rules_is_always_true():
    assert rules_match({}, {"customer_group_ids": []}) is True


def test_rules_match_detects_intersection():
    assert rules_match({"customer_group_id": ["a", "b"]}, {"customer_group_ids": ["b"]}) is True
    assert rules_match({"customer_group_id": ["a", "b"]}, {"customer_group_ids": ["c"]}) is False
price-suppression.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDefaultPriceWronglySuppressed, rulesMatch } from "./find-suppressed-default-price.js";

test("not suppressed when not from price list", () => {
  const result = isDefaultPriceWronglySuppressed({
    calculatedAmount: 1000,
    isCalculatedPriceFromPriceList: false,
    priceListRules: {},
    requestContext: { customerGroupIds: [] },
    defaultAmountForCurrency: 1200,
  });
  assert.equal(result.suppressed, false);
  assert.equal(result.reason, "none");
});

test("not suppressed when no default to compare", () => {
  const result = isDefaultPriceWronglySuppressed({
    calculatedAmount: 1000,
    isCalculatedPriceFromPriceList: true,
    priceListRules: {},
    requestContext: { customerGroupIds: [] },
    defaultAmountForCurrency: null,
  });
  assert.equal(result.suppressed, false);
});

test("suppressed when rules do not match context", () => {
  const result = isDefaultPriceWronglySuppressed({
    calculatedAmount: 900,
    isCalculatedPriceFromPriceList: true,
    priceListRules: { customer_group_id: ["cusgrp_vip"] },
    requestContext: { customerGroupIds: ["cusgrp_general"] },
    defaultAmountForCurrency: 1200,
  });
  assert.equal(result.suppressed, true);
  assert.equal(result.reason, "rules_mismatch");
});

test("suppressed when price list amount higher than default", () => {
  const result = isDefaultPriceWronglySuppressed({
    calculatedAmount: 1500,
    isCalculatedPriceFromPriceList: true,
    priceListRules: {},
    requestContext: { customerGroupIds: [] },
    defaultAmountForCurrency: 1200,
  });
  assert.equal(result.suppressed, true);
  assert.equal(result.reason, "higher_than_default");
});

test("not suppressed when rules match and price is lower", () => {
  const result = isDefaultPriceWronglySuppressed({
    calculatedAmount: 900,
    isCalculatedPriceFromPriceList: true,
    priceListRules: { customer_group_id: ["cusgrp_vip"] },
    requestContext: { customerGroupIds: ["cusgrp_vip"] },
    defaultAmountForCurrency: 1200,
  });
  assert.equal(result.suppressed, false);
  assert.equal(result.reason, "none");
});

test("rulesMatch with no rules is always true", () => {
  assert.equal(rulesMatch({}, { customerGroupIds: [] }), true);
});

test("rulesMatch detects intersection", () => {
  assert.equal(rulesMatch({ customer_group_id: ["a", "b"] }, { customerGroupIds: ["b"] }), true);
  assert.equal(rulesMatch({ customer_group_id: ["a", "b"] }, { customerGroupIds: ["c"] }), false);
});

Case studies

VIP pricing

A wholesale price list leaked to the general storefront

A homeware brand built a price list scoped to a VIP wholesale customer group, expecting only that group to see the special rate. Once the price list went active, general storefront shoppers started seeing the same wholesale amount, and support could not explain why the catalog price had silently changed for everyone.

Running the script against the storefront's region and currency showed exactly which variants had is_calculated_price_price_list true for a context whose customer group did not match the list's rules. The team kept the price list for its intended VIP group and added a workaround price row so the general audience saw the correct number again.

Expired campaign

A seasonal discount kept charging more than the regular price

A seasonal campaign price list had one stale price row left over from a prior test, priced higher than the variant's current default. Once the list was active for its campaign window, that stale row was served instead of the lower default, and a handful of products quietly got more expensive during a sale.

The script flagged the exact variant and currency combination with higher_than_default as the reason, along with the calculated amount and the default amount side by side. The merchant corrected the stale row on the price list directly, since deleting the whole list would have removed a discount still valid for other products on it.

What good looks like

After running this on a schedule, every price list keeps doing its intended job for its intended audience, and nothing outside that audience quietly inherits its price. A price list that is actually a discount stays a discount, because a stale or wrong price row on it gets caught before it starts charging more than the regular price. Nothing is changed automatically, so a legitimate promotion is never accidentally switched off.

FAQ

Why does a Medusa price list hide the default price for everyone, not just its intended audience?

Medusa v2's pricing module resolves calculated_price by first checking whether any price list price matches the given context. Once at least one valid price list price exists for a price set, the price-selection strategy restricts its candidate pool to price list prices and never falls back to compare against the variant's default prices, even when the price list rules do not match the current request or the default price is actually lower.

Is it safe to delete or deactivate the price list to fix this?

Not automatically. Deactivating a price list can remove a legitimate promotion that the merchant still wants active for its correctly scoped audience. The safe approach is to detect and report every affected variant and currency, then let a merchant decide, optionally adding an explicit price row on the list itself so the price list's own price is always the correct one served.

How do I detect which variants are affected by this bug?

List active price lists, resolve the variants and default prices behind each one, then request calculated_price for each variant with the relevant region and currency context. Flag any result where is_calculated_price_price_list is true and the calculated amount is higher than the matching default amount, or where the price list rules do not match the request context yet its price was still used.

Related field notes

Citations

On the problem:

  1. Bug: calculated_price context works only with price lists and does not take other prices into consideration (medusajs/medusa#10613). github.com/medusajs/medusa/issues/10613
  2. Price list not applied (medusajs/medusa#9625). github.com/medusajs/medusa/issues/9625
  3. Bug: Prices from price lists are not applicable when adding to cart (medusajs/medusa#10490). github.com/medusajs/medusa/issues/10490

On the solution:

  1. Pricing Module Concepts, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/pricing/concepts
  2. Prices Calculation, Medusa Documentation. docs.medusajs.com/resources/commerce-modules/pricing/price-calculation
  3. CalculatedPriceSet Interface, Medusa Documentation. docs.medusajs.com/resources/references/pricing/interfaces/pricing.CalculatedPriceSet

Stuck on a tricky one?

If you have a problem in Medusa pricing, promotions, carts, or checkout 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 catch a pricing surprise?

If this saved you from a stale price list quietly overcharging shoppers, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa field notes