Skip to content

Diagnostic Pricing & Promotions

Promotion ignores its sales channel condition and applies anyway

The promotion has a rule that says it only belongs to one sales channel. The dashboard shows it. The rule is saved. And it still shows up as a discount on carts from a completely different channel. Nobody edited the rule and nobody removed the condition. Somewhere between the rule and the cart, the channel check just never ran. Here is why that happens and a script that finds every order where it actually did.

Python and Node.js Medusa Admin API Report only, no auto-mutate
A weekend sale sign
Photo by Markus Spiske on Unsplash
The short answer

Applying or computing promotions on a cart in Medusa v2 runs through a cart refresh workflow that builds a rule evaluation context from a fixed allowlist of cart fields, cartFieldsForRefreshSteps. That allowlist historically omitted sales_channel_id, so a PromotionRule with attribute: "sales_channel_id" had no value to compare against on the cart side, and the condition was effectively skipped rather than enforced. That let the promotion apply on carts outside its intended sales channel. It is tracked as medusajs/medusa#10089 and fixed in PR #10090. Run a small Python or Node.js script that pulls every promotion with a channel rule, cross-checks it against recent orders, and reports every order where the channel condition was actually ignored. It is a report-only tool, since this is a core enforcement bug and not something safe to auto-fix by mutating promotions or historical orders.

The problem in plain words

A sales channel scoped promotion is a simple idea. You sell the same catalog through a web store and a wholesale channel, or through a storefront and a point of sale app, and you want a discount to exist only on one of them. You add a PromotionRule with attribute: "sales_channel_id", list the channel ids it should match, and expect Medusa to enforce it the same way it enforces a currency or region rule.

The rule looks correct in the admin. The promotion looks active. But promotions and sales channels are two separate modules in Medusa, connected only through rule attributes rather than a hard foreign key. When a cart is refreshed and promotions are recomputed, the workflow builds its evaluation context from a specific, fixed list of cart fields. If sales_channel_id is not on that list, the rule has nothing real to compare against, and Medusa treats the condition as satisfied rather than failing it. The promotion applies everywhere, and nothing in the response or the logs tells you the channel check never actually ran.

Promotion rule attribute: sales_channel_id values: [sc_web] Cart refresh workflow cartFieldsForRefreshSteps omits sales_channel_id nothing to compare Condition skipped not enforced Wrong channel gets discount
The rule exists and looks correct in the admin. But the workflow never hydrates the one field it needs to check it against, so the condition passes by default instead of failing.

Why it happens

The rule engine itself is not broken. It compares an attribute, an operator, and a list of values the same way for every rule. The gap is one step earlier, in what gets loaded onto the cart before that comparison runs. A few concrete ways this shows up:

This is a common source of confusion because nothing errors and nothing looks wrong in the promotion's own configuration. The rule is there, the values are correct, the status is active. The only way to see the leak is to compare what a promotion's channel rule says against what actually happened on real orders. Medusa's own tracker has reports of promotions applying regardless of sales channel conditions, alongside a broader pattern of promotion evaluation issues. See the citations at the end for the exact threads and docs.

The key insight

This is a data integrity and enforcement bug in core rule evaluation, not a misconfigured promotion, so it is not safe to fix by rewriting promotions or editing historical orders. The safe pattern is to detect and report: pull every promotion's channel rule, pull the orders it actually touched, and flag the exact records where the channel that was applied does not match the channel the rule allowed. A human decides whether to upgrade, tighten the rule, or leave history alone.

The fix, as a flow

We do not touch checkout and we do not rewrite promotion rules on our own. We list every promotion that carries a sales_channel_id rule, list recent orders along with the promotions that were actually applied to them, and compare each order's real sales channel against the rule's allowed values. Anything that does not line up is a confirmed leak and gets reported with enough detail to act on.

List channel scoped promos rules with sales_channel_id List recent orders sales_channel_id, promotions Check order vs rule operator, values, channel id Channel allowed? no yes, no leak Report leak order, promo, channels
The script only reports. It never edits a promotion's rules or an order's history, because deciding what to do with a confirmed leak is a merchant and engineering decision.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend and an admin user with rights to read promotions, orders, and sales channels. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.

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, this fix only ever reports
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 DRY_RUN="true"   // start safe, this fix only ever reports
2

Authenticate against the Admin API

Every call needs a Bearer token from the emailpass auth route. A small helper exchanges credentials once, and every other request reuses it.

step2.py
import os, requests

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

async function getToken() {
  const res = await fetch(`${BASE_URL}/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;
}
3

List promotions with a sales channel rule

Ask for every promotion's rules and pull out the ones where attribute is sales_channel_id, keeping the operator and the values array. That is what any real cart or order should have been checked against.

step3.py
PROMOTION_FIELDS = "id,code,status,*application_method,*rules"

def list_promotions(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions",
        params={"fields": PROMOTION_FIELDS, "limit": 100},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotions"]

def channel_rules(promotion):
    return [r for r in (promotion.get("rules") or []) if r.get("attribute") == "sales_channel_id"]
step3.js
const PROMOTION_FIELDS = "id,code,status,*application_method,*rules";

async function listPromotions(token) {
  const url = new URL(`${BASE_URL}/admin/promotions`);
  url.searchParams.set("fields", PROMOTION_FIELDS);
  url.searchParams.set("limit", "100");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.promotions;
}

function channelRules(promotion) {
  return (promotion.rules || []).filter((r) => r.attribute === "sales_channel_id");
}
4

List orders with the promotions actually applied to them

Page through recent orders and ask for sales_channel_id plus the promotions that were applied, including each promotion's rules. This is the real world side of the comparison: what channel the order was placed on, and which promotions actually touched it.

step4.py
ORDER_FIELDS = "id,sales_channel_id,promotions.id,promotions.code,promotions.rules,total,currency_code"

def list_orders(token, limit=100):
    headers = {"Authorization": f"Bearer {token}"}
    offset = 0
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/orders",
            params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        orders = body["orders"]
        if not orders:
            return
        for order in orders:
            yield order
        offset += limit
        if offset >= body["count"]:
            return
step4.js
const ORDER_FIELDS = "id,sales_channel_id,promotions.id,promotions.code,promotions.rules,total,currency_code";

async function* listOrders(token, limit = 100) {
  let offset = 0;
  while (true) {
    const url = new URL(`${BASE_URL}/admin/orders`);
    url.searchParams.set("fields", ORDER_FIELDS);
    url.searchParams.set("limit", String(limit));
    url.searchParams.set("offset", String(offset));
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    if (!body.orders.length) return;
    for (const order of body.orders) yield order;
    offset += limit;
    if (offset >= body.count) return;
  }
}
5

Decide, with one pure function

Keep the enforcement rule that Medusa should have applied in its own function. It filters a promotion's rules down to sales_channel_id entries, and if there are none, the promotion has no channel restriction and any channel is allowed. If there are channel rules, every one of them must pass against the cart's actual channel id, respecting eq and in as membership checks and ne and nin as exclusion checks. An unknown operator or a missing channel id fails closed, the same direction Medusa's own rule evaluation is supposed to fail in.

decide.py
def is_promotion_allowed_for_channel(rules, cart_sales_channel_id):
    channel_rules = [r for r in rules if r.get("attribute") == "sales_channel_id"]
    if not channel_rules:
        return True  # no channel restriction

    for r in channel_rules:
        if cart_sales_channel_id is None:
            return False  # can't satisfy a channel rule with no channel
        is_member = cart_sales_channel_id in (r.get("values") or [])
        operator = r.get("operator")
        if operator in ("eq", "in"):
            if not is_member:
                return False
        elif operator in ("ne", "nin"):
            if is_member:
                return False
        else:
            return False  # unknown operator: fail closed
    return True
decide.js
export function isPromotionAllowedForChannel(rules, cartSalesChannelId) {
  const channelRules = rules.filter((r) => r.attribute === "sales_channel_id");
  if (channelRules.length === 0) return true; // no channel restriction

  return channelRules.every((r) => {
    if (cartSalesChannelId == null) return false; // can't satisfy a channel rule with no channel
    const isMember = r.values.includes(cartSalesChannelId);
    if (r.operator === "eq" || r.operator === "in") return isMember;
    if (r.operator === "ne" || r.operator === "nin") return !isMember;
    return false; // unknown operator: fail closed
  });
}
6

Cross-check every order and report, never auto-mutate

For each order, run every applied promotion's rules through is_promotion_allowed_for_channel against the order's real sales_channel_id. Anything that returns false is a confirmed leak: a promotion that should never have applied to this channel actually did. Resolve human readable channel names from /admin/sales-channels for the report. Every run is gated behind DRY_RUN, and even when it is false the script never writes anything to a promotion or an order. It only prints what a human should look at, because this bug is fixed by a Medusa core upgrade, not by rewriting history.

Run it safe

This script never mutates a promotion or an order. It only reports confirmed leaks: {promotion_id, code, expected_sales_channel_ids, order_id, actual_sales_channel_id, order_total, currency_code}. The real fix is upgrading to a Medusa core version that includes the cartFieldsForRefreshSteps fix from PR #10090, plus, as a stronger guard, a custom validation step or a subscriber on order placed that re-checks channel rules and only ever tightens a misconfigured rule going forward, never touches historical orders.

The full code

Here is the complete script in one file for each language. It authenticates, lists channel scoped promotions and recent orders, cross-checks every applied promotion against the order's real channel, and prints a report for every confirmed leak.

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_channel_leaks.py
"""Find Medusa orders where a sales-channel scoped promotion applied outside its channel.
Cross-checks every promotion's sales_channel_id rule against the orders it actually
touched and reports confirmed leaks. Never mutates a promotion or an order.
Safe to run again and again.
"""
import os
import logging

import requests

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

BASE_URL = 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")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDER_LIMIT = int(os.environ.get("ORDER_LIMIT", "100"))

PROMOTION_FIELDS = "id,code,status,*application_method,*rules"
ORDER_FIELDS = "id,sales_channel_id,promotions.id,promotions.code,promotions.rules,total,currency_code"


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_promotions(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions",
        params={"fields": PROMOTION_FIELDS, "limit": 100},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotions"]


def list_sales_channels(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/sales-channels",
        params={"fields": "id,name", "limit": 100},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return {sc["id"]: sc["name"] for sc in r.json()["sales_channels"]}


def list_orders(token, limit=ORDER_LIMIT):
    headers = {"Authorization": f"Bearer {token}"}
    offset = 0
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/orders",
            params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        orders = body["orders"]
        if not orders:
            return
        for order in orders:
            yield order
        offset += limit
        if offset >= body["count"]:
            return


def channel_rules(promotion):
    return [r for r in (promotion.get("rules") or []) if r.get("attribute") == "sales_channel_id"]


def is_promotion_allowed_for_channel(rules, cart_sales_channel_id):
    """Pure: returns True when a promotion's sales_channel_id rules (if any) are
    satisfied by cart_sales_channel_id. No channel rules means no restriction.
    Unknown operators and a missing channel id fail closed (return False),
    mirroring how a channel condition is supposed to be enforced.
    """
    channel_rules_ = [r for r in rules if r.get("attribute") == "sales_channel_id"]
    if not channel_rules_:
        return True

    for r in channel_rules_:
        if cart_sales_channel_id is None:
            return False
        is_member = cart_sales_channel_id in (r.get("values") or [])
        operator = r.get("operator")
        if operator in ("eq", "in"):
            if not is_member:
                return False
        elif operator in ("ne", "nin"):
            if is_member:
                return False
        else:
            return False
    return True


def find_leaks(promotions, orders, channel_names):
    """Pure: returns a list of leak reports, one per (order, promotion) pair where
    the promotion has a sales_channel_id rule and the order's channel violates it.
    """
    promo_by_id = {p["id"]: p for p in promotions}
    leaks = []
    for order in orders:
        order_channel = order.get("sales_channel_id")
        for applied in order.get("promotions") or []:
            promotion = promo_by_id.get(applied["id"], applied)
            rules = promotion.get("rules") or applied.get("rules") or []
            if not channel_rules(promotion) and not channel_rules(applied):
                continue
            if is_promotion_allowed_for_channel(rules, order_channel):
                continue
            allowed_ids = sorted({v for r in channel_rules(promotion) or channel_rules(applied) for v in (r.get("values") or [])})
            leaks.append({
                "promotion_id": promotion.get("id"),
                "code": promotion.get("code"),
                "expected_sales_channel_ids": allowed_ids,
                "order_id": order.get("id"),
                "actual_sales_channel_id": order_channel,
                "actual_sales_channel_name": channel_names.get(order_channel, order_channel),
                "order_total": order.get("total"),
                "currency_code": order.get("currency_code"),
            })
    return leaks


def run():
    token = get_token()
    promotions = [p for p in list_promotions(token) if channel_rules(p)]
    channel_names = list_sales_channels(token)
    orders = list(list_orders(token))

    leaks = find_leaks(promotions, orders, channel_names)
    if not leaks:
        log.info("No sales-channel leaks found across %d order(s).", len(orders))
        return

    for leak in leaks:
        log.warning(
            "Promotion %s (%s) applied on order %s in channel %s, expected one of %s. Total %s %s. %s",
            leak["promotion_id"], leak["code"], leak["order_id"],
            leak["actual_sales_channel_name"], leak["expected_sales_channel_ids"],
            leak["order_total"], leak["currency_code"],
            "Would flag." if DRY_RUN else "Flagged.",
        )
    log.info("Done. %d confirmed leak(s) found. Report only, nothing was changed.", len(leaks))


if __name__ == "__main__":
    run()
find-channel-leaks.js
/**
 * Find Medusa orders where a sales-channel scoped promotion applied outside its channel.
 * Cross-checks every promotion's sales_channel_id rule against the orders it actually
 * touched and reports confirmed leaks. Never mutates a promotion or an order.
 * Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const BASE_URL = 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";
const ORDER_LIMIT = Number(process.env.ORDER_LIMIT || 100);

const PROMOTION_FIELDS = "id,code,status,*application_method,*rules";
const ORDER_FIELDS = "id,sales_channel_id,promotions.id,promotions.code,promotions.rules,total,currency_code";

function channelRules(promotion) {
  return (promotion.rules || []).filter((r) => r.attribute === "sales_channel_id");
}

/**
 * Pure: returns true when a promotion's sales_channel_id rules (if any) are
 * satisfied by cartSalesChannelId. No channel rules means no restriction.
 * Unknown operators and a missing channel id fail closed (return false),
 * mirroring how a channel condition is supposed to be enforced.
 */
export function isPromotionAllowedForChannel(rules, cartSalesChannelId) {
  const channelRules_ = rules.filter((r) => r.attribute === "sales_channel_id");
  if (channelRules_.length === 0) return true;

  return channelRules_.every((r) => {
    if (cartSalesChannelId == null) return false;
    const isMember = r.values.includes(cartSalesChannelId);
    if (r.operator === "eq" || r.operator === "in") return isMember;
    if (r.operator === "ne" || r.operator === "nin") return !isMember;
    return false;
  });
}

/**
 * Pure: returns a list of leak reports, one per (order, promotion) pair where
 * the promotion has a sales_channel_id rule and the order's channel violates it.
 */
export function findLeaks(promotions, orders, channelNames) {
  const promoById = new Map(promotions.map((p) => [p.id, p]));
  const leaks = [];
  for (const order of orders) {
    const orderChannel = order.sales_channel_id;
    for (const applied of order.promotions || []) {
      const promotion = promoById.get(applied.id) || applied;
      const rules = promotion.rules || applied.rules || [];
      const hasChannelRule = channelRules(promotion).length > 0 || channelRules(applied).length > 0;
      if (!hasChannelRule) continue;
      if (isPromotionAllowedForChannel(rules, orderChannel)) continue;
      const allowedIds = [
        ...new Set(
          [...channelRules(promotion), ...channelRules(applied)].flatMap((r) => r.values || [])
        ),
      ].sort();
      leaks.push({
        promotionId: promotion.id,
        code: promotion.code,
        expectedSalesChannelIds: allowedIds,
        orderId: order.id,
        actualSalesChannelId: orderChannel,
        actualSalesChannelName: channelNames[orderChannel] || orderChannel,
        orderTotal: order.total,
        currencyCode: order.currency_code,
      });
    }
  }
  return leaks;
}

async function getToken() {
  const res = await fetch(`${BASE_URL}/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 listPromotions(token) {
  const url = new URL(`${BASE_URL}/admin/promotions`);
  url.searchParams.set("fields", PROMOTION_FIELDS);
  url.searchParams.set("limit", "100");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.promotions;
}

async function listSalesChannels(token) {
  const url = new URL(`${BASE_URL}/admin/sales-channels`);
  url.searchParams.set("fields", "id,name");
  url.searchParams.set("limit", "100");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return Object.fromEntries(body.sales_channels.map((sc) => [sc.id, sc.name]));
}

async function* listOrders(token, limit = ORDER_LIMIT) {
  let offset = 0;
  while (true) {
    const url = new URL(`${BASE_URL}/admin/orders`);
    url.searchParams.set("fields", ORDER_FIELDS);
    url.searchParams.set("limit", String(limit));
    url.searchParams.set("offset", String(offset));
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    if (!res.ok) throw new Error(`Medusa ${res.status}`);
    const body = await res.json();
    if (!body.orders.length) return;
    for (const order of body.orders) yield order;
    offset += limit;
    if (offset >= body.count) return;
  }
}

export async function run() {
  const token = await getToken();
  const allPromotions = await listPromotions(token);
  const promotions = allPromotions.filter((p) => channelRules(p).length > 0);
  const channelNames = await listSalesChannels(token);

  const orders = [];
  for await (const order of listOrders(token)) orders.push(order);

  const leaks = findLeaks(promotions, orders, channelNames);
  if (leaks.length === 0) {
    console.log(`No sales-channel leaks found across ${orders.length} order(s).`);
    return;
  }

  for (const leak of leaks) {
    console.warn(
      `Promotion ${leak.promotionId} (${leak.code}) applied on order ${leak.orderId} in channel ${leak.actualSalesChannelName}, expected one of ${JSON.stringify(leak.expectedSalesChannelIds)}. Total ${leak.orderTotal} ${leak.currencyCode}. ${DRY_RUN ? "Would flag." : "Flagged."}`
    );
  }
  console.log(`Done. ${leaks.length} confirmed leak(s) found. Report only, nothing was changed.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The function most worth testing is the channel check, because it decides whether a real order gets flagged as a leak. Because is_promotion_allowed_for_channel is pure, the tests feed in plain rule lists and channel ids, no Medusa backend required.

test_channel_condition.py
from find_channel_leaks import is_promotion_allowed_for_channel, find_leaks


def rule(**over):
    base = {"attribute": "sales_channel_id", "operator": "eq", "values": ["sc_web"]}
    base.update(over)
    return base


def test_no_channel_rules_means_allowed_anywhere():
    assert is_promotion_allowed_for_channel([], "sc_pos") is True


def test_eq_allows_matching_channel():
    assert is_promotion_allowed_for_channel([rule()], "sc_web") is True


def test_eq_blocks_other_channel():
    assert is_promotion_allowed_for_channel([rule()], "sc_pos") is False


def test_in_allows_any_listed_channel():
    r = rule(operator="in", values=["sc_web", "sc_wholesale"])
    assert is_promotion_allowed_for_channel([r], "sc_wholesale") is True


def test_ne_blocks_the_excluded_channel():
    r = rule(operator="ne", values=["sc_pos"])
    assert is_promotion_allowed_for_channel([r], "sc_pos") is False


def test_nin_allows_channel_not_in_list():
    r = rule(operator="nin", values=["sc_pos"])
    assert is_promotion_allowed_for_channel([r], "sc_web") is True


def test_missing_cart_channel_fails_closed():
    assert is_promotion_allowed_for_channel([rule()], None) is False


def test_unknown_operator_fails_closed():
    assert is_promotion_allowed_for_channel([rule(operator="regex")], "sc_web") is False


def test_all_channel_rules_must_pass():
    rules = [rule(values=["sc_web"]), rule(operator="ne", values=["sc_web"])]
    assert is_promotion_allowed_for_channel(rules, "sc_web") is False


def test_find_leaks_flags_order_outside_promotion_channel():
    promotions = [{"id": "promo_1", "code": "WEB10", "rules": [rule()]}]
    orders = [{
        "id": "order_1",
        "sales_channel_id": "sc_pos",
        "total": 100,
        "currency_code": "usd",
        "promotions": [{"id": "promo_1", "code": "WEB10", "rules": [rule()]}],
    }]
    leaks = find_leaks(promotions, orders, {"sc_pos": "Point of Sale"})
    assert len(leaks) == 1
    assert leaks[0]["order_id"] == "order_1"
    assert leaks[0]["expected_sales_channel_ids"] == ["sc_web"]


def test_find_leaks_ignores_orders_in_the_right_channel():
    promotions = [{"id": "promo_1", "code": "WEB10", "rules": [rule()]}]
    orders = [{
        "id": "order_2",
        "sales_channel_id": "sc_web",
        "total": 50,
        "currency_code": "usd",
        "promotions": [{"id": "promo_1", "code": "WEB10", "rules": [rule()]}],
    }]
    assert find_leaks(promotions, orders, {}) == []


def test_find_leaks_ignores_promotions_without_channel_rules():
    promotions = [{"id": "promo_2", "code": "SALE5", "rules": []}]
    orders = [{
        "id": "order_3",
        "sales_channel_id": "sc_pos",
        "total": 20,
        "currency_code": "usd",
        "promotions": [{"id": "promo_2", "code": "SALE5", "rules": []}],
    }]
    assert find_leaks(promotions, orders, {}) == []
channel-condition.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isPromotionAllowedForChannel, findLeaks } from "./find-channel-leaks.js";

const rule = (over = {}) => ({ attribute: "sales_channel_id", operator: "eq", values: ["sc_web"], ...over });

test("no channel rules means allowed anywhere", () => {
  assert.equal(isPromotionAllowedForChannel([], "sc_pos"), true);
});

test("eq allows matching channel", () => {
  assert.equal(isPromotionAllowedForChannel([rule()], "sc_web"), true);
});

test("eq blocks other channel", () => {
  assert.equal(isPromotionAllowedForChannel([rule()], "sc_pos"), false);
});

test("in allows any listed channel", () => {
  const r = rule({ operator: "in", values: ["sc_web", "sc_wholesale"] });
  assert.equal(isPromotionAllowedForChannel([r], "sc_wholesale"), true);
});

test("ne blocks the excluded channel", () => {
  const r = rule({ operator: "ne", values: ["sc_pos"] });
  assert.equal(isPromotionAllowedForChannel([r], "sc_pos"), false);
});

test("nin allows channel not in list", () => {
  const r = rule({ operator: "nin", values: ["sc_pos"] });
  assert.equal(isPromotionAllowedForChannel([r], "sc_web"), true);
});

test("missing cart channel fails closed", () => {
  assert.equal(isPromotionAllowedForChannel([rule()], null), false);
});

test("unknown operator fails closed", () => {
  assert.equal(isPromotionAllowedForChannel([rule({ operator: "regex" })], "sc_web"), false);
});

test("all channel rules must pass", () => {
  const rules = [rule({ values: ["sc_web"] }), rule({ operator: "ne", values: ["sc_web"] })];
  assert.equal(isPromotionAllowedForChannel(rules, "sc_web"), false);
});

test("findLeaks flags order outside promotion channel", () => {
  const promotions = [{ id: "promo_1", code: "WEB10", rules: [rule()] }];
  const orders = [{
    id: "order_1",
    sales_channel_id: "sc_pos",
    total: 100,
    currency_code: "usd",
    promotions: [{ id: "promo_1", code: "WEB10", rules: [rule()] }],
  }];
  const leaks = findLeaks(promotions, orders, { sc_pos: "Point of Sale" });
  assert.equal(leaks.length, 1);
  assert.equal(leaks[0].orderId, "order_1");
  assert.deepEqual(leaks[0].expectedSalesChannelIds, ["sc_web"]);
});

test("findLeaks ignores orders in the right channel", () => {
  const promotions = [{ id: "promo_1", code: "WEB10", rules: [rule()] }];
  const orders = [{
    id: "order_2",
    sales_channel_id: "sc_web",
    total: 50,
    currency_code: "usd",
    promotions: [{ id: "promo_1", code: "WEB10", rules: [rule()] }],
  }];
  assert.deepEqual(findLeaks(promotions, orders, {}), []);
});

test("findLeaks ignores promotions without channel rules", () => {
  const promotions = [{ id: "promo_2", code: "SALE5", rules: [] }];
  const orders = [{
    id: "order_3",
    sales_channel_id: "sc_pos",
    total: 20,
    currency_code: "usd",
    promotions: [{ id: "promo_2", code: "SALE5", rules: [] }],
  }];
  assert.deepEqual(findLeaks(promotions, orders, {}), []);
});

Case studies

Wholesale leak

The web-only discount that reached wholesale buyers

A store ran a storefront-only automatic promotion scoped with a sales_channel_id eq sc_web rule, meant to keep it away from the wholesale channel where margins were already thin. For weeks it seemed to work, since most wholesale orders came through a different flow. Then a wholesale buyer placed an order through a shared cart path, and the discount applied anyway. Finance flagged a run of orders with margins lower than the wholesale pricing model should have allowed.

The leak finder pulled every order's sales_channel_id and the promotions applied to it, compared each against the promotion's channel rule, and confirmed a dozen wholesale orders had gotten the web discount. The team upgraded past PR #10090 and used the report to credit the affected wholesale accounts correctly instead of guessing.

POS exclusion

An in-store exclusion that quietly stopped excluding

A retailer used an ne rule to keep an online clearance code away from in-store point of sale checkouts, where the same SKUs were sold at full markup for a different reason. The rule had worked correctly for months. After a platform upgrade touched cart refresh internals, in-store staff started reporting that the clearance discount was showing up on the POS terminal too, with no configuration change on their end.

Running the cross-check against a week of orders showed several POS-channel orders carrying the promotion the ne rule was supposed to keep out. That confirmed the leak was systemic and not a one-off staff mistake, which pointed the team straight at the core cart refresh behavior instead of chasing a training issue.

What good looks like

Run this check any time a channel scoped promotion is live, pointed at a recent window of orders. It never rewrites a promotion or an order, it only tells you exactly which orders got a discount their channel rule should have blocked. Use the report to justify an upgrade to a Medusa core version with the cartFieldsForRefreshSteps fix, and to decide, deliberately and by hand, whether any historical orders need a manual adjustment. The rule engine stays honest, and channel scoped pricing means what it says again.

FAQ

Why does a Medusa promotion apply outside the sales channel it is scoped to?

Applying or computing promotions on a cart runs through a cart refresh workflow that builds its rule evaluation context from a fixed list of cart fields. When that list does not include sales_channel_id, a PromotionRule with attribute sales_channel_id has nothing to compare against on the cart side, so the condition is skipped instead of enforced, and the promotion applies on carts outside the channel it was meant for.

Is this a Medusa bug or a misconfigured promotion?

It is a core enforcement bug, not a per-record mistake. It is tracked as medusajs/medusa issue 10089 and fixed in pull request 10090 by adding sales_channel_id to the fields the cart refresh workflow hydrates before rule evaluation. Because promotions and sales channels are linked only through rule attributes and not a hard foreign key, any workflow step that forgets to hydrate that field will quietly reproduce the same leak.

How do I find which orders leaked a channel scoped promotion?

List promotions with fields=id,code,status,*application_method,*rules and pull out every rule whose attribute is sales_channel_id, noting its operator and values. Then list recent orders with fields=id,sales_channel_id,promotions.id,promotions.code,promotions.rules and compare each order's sales_channel_id against the values of every applied promotion's channel rule. An eq or in rule whose values do not include the order's channel, or an ne or nin rule whose values do include it, is a confirmed leak.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #10089: Promotion is not working with sales channel conditions. github.com/medusajs/medusa/issues/10089
  2. medusajs/medusa GitHub pull request #10090: fix(promotion): missing sales_channel_id. github.com/medusajs/medusa/pull/10090
  3. medusajs/medusa GitHub issue #11299: Issues with promotions. github.com/medusajs/medusa/issues/11299

On the solution:

  1. Medusa Documentation: Promotion module concepts. docs.medusajs.com/resources/commerce-modules/promotion/concepts
  2. Medusa Documentation: the PromotionRule data model reference. docs.medusajs.com/resources/references/promotion/models/PromotionRule
  3. Medusa Documentation: Promotions adjustments in carts. docs.medusajs.com/resources/commerce-modules/cart/promotions

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.

Contact me on LinkedIn

Did this catch a channel leak?

If this saved you a support ticket or a margin problem, 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