Skip to content

Diagnostic Cart Price Rules and Coupons

Automatic no coupon rule stops applying once a coupon rule exists

You built a No Coupon cart price rule so every shopper gets a small automatic discount. It worked fine on its own. Then you launched a seasonal coupon rule, and the automatic discount quietly stopped showing up for anyone who enters a code. Nothing in either rule's conditions changed. Here is why Magento drops the automatic rule from consideration the moment a coupon is entered, and a small script that finds every rule pair where this is happening.

Python and Node.js REST API, read only detection Safe by default (report first)
A discount coupon
Photo by Tamanna Rumee on Unsplash
The short answer

Magento builds the list of cart price rules valid for a quote in one place, Magento\SalesRule\Model\ResourceModel\Rule\Collection::setValidationFilter(). It joins salesrule_coupon and filters by coupon_type, by sort_order (Priority), and, once a coupon code has been entered, by the entered code matching rule_coupons.code. That coupon match has historically been applied as a hard AND rather than an OR against the no-coupon rule type, so entering any coupon code excludes your automatic, no-coupon rule from the candidate set entirely, it is not that the rule fails its conditions, it is never fetched at all. Even when both rules are fetched, a coupon rule with an equal or higher priority (lower or equal sort_order) and Discard Subsequent Rules set to yes (stop_rules_processing) will stop the automatic rule's discount from being applied. A script can pull every active rule over GET /rest/V1/salesRules, read coupon_type, sort_order, and stop_rules_processing, and flag any no-coupon rule that a scoped coupon rule is shadowing. Full code, tests, and a dry run guard are below.

The problem in plain words

A No Coupon cart price rule, one with Coupon set to No Coupon, is meant to apply itself automatically to every cart that matches its conditions, no code required. That is the whole appeal of it: a blanket discount, a free gift, a threshold based promotion that just works in the background.

The moment a merchant adds a second rule that does require a coupon and a shopper types that code into the cart, the automatic rule can go silent. Its conditions have not changed. Its Active flag is still yes. The website and customer group scope still matches the same cart. But the discount it used to add is simply not there anymore. Support tickets describe this as the automatic rule getting disabled by the coupon rule, and from the merchant's chair that is exactly what it looks like.

Coupon entered shopper types a code setValidationFilter code joined as hard AND no-coupon rule excluded Not in candidate set never even evaluated Automatic discount gone
Once a code is entered, the rule query filters on that code as a strict AND against the no-coupon rule type, so the automatic rule is dropped before its conditions are ever checked.

Why it happens

This is a long running, well documented pattern in the core issue tracker, not a one-off bug in a single release. See the citations at the end for the exact threads.

The key insight

A no-coupon rule being shadowed is a scope and ordering problem, not a conditions problem. The rule itself is still active and its conditions still match the cart, so a script cannot detect this by inspecting the no-coupon rule alone. It has to look at both rules together, their coupon_type, whether they share a website and customer group, their relative sort_order, and whether the coupon rule discards subsequent rules. Only that pairing tells you a shadow exists.

The fix, as a flow

We do not touch the live rule collection query or any core code. We add a job that pulls every active cart price rule, partitions them into no-coupon and coupon rules, and for each no-coupon rule checks whether a scoped coupon rule outranks it and discards subsequent rules. Everything is reported, nothing is written, unless a human explicitly asks the script to print a proposed fix.

Scheduled job runs on a timer List active rules, split by coupon_type salesRules Match shared scope website_ids, customer_group_ids Equal/higher priority and discards rules? yes no, report ok Flag for review rule id pair reported
The script only reports a shadowed pair. Changing priority or Discard Subsequent Rules stays a human decision reviewed as a printed diff.

Build it step by step

1

Get an admin bearer token

Authenticate the way any Magento REST client does. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true"   # start safe, prints a proposed fix diff only, never writes
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true"   // start safe, prints a proposed fix diff only, never writes
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List every active cart price rule

Search /salesRules filtered on is_active equal to one, and read back rule_id, coupon_type, sort_order, stop_rules_processing, website_ids, and customer_group_ids on each. Page through with pageSize so a store with many promotions is not truncated.

step3.py
def active_cart_price_rules(page_size=100):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "is_active",
        "searchCriteria[filterGroups][0][filters][0][value]": 1,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
    }
    return magento_get("/salesRules", params)["items"]
step3.js
async function activeCartPriceRules(pageSize = 100) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "is_active",
    "searchCriteria[filterGroups][0][filters][0][value]": 1,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
  };
  const data = await magentoGet("/salesRules", params);
  return data.items;
}
4

Resolve a coupon code to its rule, when you need to confirm one case

If support hands you a specific coupon code that stopped triggering the automatic discount, search /coupons filtered by code to get its rule_id, then pull that rule directly. This is optional for a full scan, but it is the fastest path when investigating a single reported ticket.

step4.py
def rule_id_for_coupon_code(code):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "code",
        "searchCriteria[filterGroups][0][filters][0][value]": code,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    items = magento_get("/coupons/search", params)["items"]
    return items[0]["rule_id"] if items else None
step4.js
async function ruleIdForCouponCode(code) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "code",
    "searchCriteria[filterGroups][0][filters][0][value]": code,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/coupons/search", params);
  const items = data.items || [];
  return items.length ? items[0].rule_id : null;
}
5

Decide, with one pure function

Keep the decision in its own function that takes the full list of rules and returns every shadowed pair. A pure function like this is easy to read and easy to test, which we do later. It splits active rules into no-coupon and coupon groups, then for every pair that shares at least one website and one customer group, it flags a shadow when the coupon rule's sort_order is equal to or lower in number than the no-coupon rule's and stop_rules_processing is true.

decide.py
def find_shadowed_no_coupon_rules(rules):
    no_coupon = [r for r in rules if r.get("is_active") and r.get("coupon_type") == "NO_COUPON"]
    coupon = [r for r in rules if r.get("is_active") and r.get("coupon_type") != "NO_COUPON"]

    conflicts = []
    for nc in no_coupon:
        for c in coupon:
            shares_website = bool(set(nc.get("website_ids") or []) & set(c.get("website_ids") or []))
            shares_group = bool(set(nc.get("customer_group_ids") or []) & set(c.get("customer_group_ids") or []))
            if not (shares_website and shares_group):
                continue
            if c.get("sort_order", 0) <= nc.get("sort_order", 0) and c.get("stop_rules_processing") is True:
                conflicts.append({
                    "noCouponRuleId": nc["rule_id"],
                    "blockingCouponRuleId": c["rule_id"],
                    "reason": "equal-or-higher-priority coupon rule discards subsequent rules",
                })
    return conflicts
decide.js
export function findShadowedNoCouponRules(rules) {
  const noCoupon = rules.filter((r) => r.is_active && r.coupon_type === "NO_COUPON");
  const coupon = rules.filter((r) => r.is_active && r.coupon_type !== "NO_COUPON");

  const conflicts = [];
  for (const nc of noCoupon) {
    for (const c of coupon) {
      const sharesWebsite = (nc.website_ids || []).some((id) => (c.website_ids || []).includes(id));
      const sharesGroup = (nc.customer_group_ids || []).some((id) => (c.customer_group_ids || []).includes(id));
      if (!(sharesWebsite && sharesGroup)) continue;
      if (c.sort_order <= nc.sort_order && c.stop_rules_processing === true) {
        conflicts.push({
          noCouponRuleId: nc.rule_id,
          blockingCouponRuleId: c.rule_id,
          reason: "equal-or-higher-priority coupon rule discards subsequent rules",
        });
      }
    }
  }
  return conflicts;
}
6

Report by default, propose a diff only when explicitly asked

The default output is a structured record per flagged pair: the no-coupon rule id, the blocking coupon rule id, and their current sort_order and stop_rules_processing values, for an operator to review. Writing a priority or Discard Subsequent Rules change is never done automatically, since it alters merchant intended promotion stacking and can silently change live pricing. Only when DRY_RUN is false and a human explicitly confirms does the script print, not execute, the PUT /rest/V1/salesRules/{rule_id} payload that would lower the coupon rule's sort_order below the automatic rule's or set stop_rules_processing to false.

Run it safe

Always start with DRY_RUN=true. Even the proposed fix, printed only when DRY_RUN=false and a human confirms, is never sent to Magento by this script. A merchant reviews the diff and applies it through the admin or a separate, deliberate call, since this mirrors the known community workaround of overriding setValidationFilter in a module rather than something a script should silently patch on production rule data.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, lists active cart price rules, finds every shadowed no-coupon rule, respects the dry run flag, and is safe to run again and again because by default it only reports.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
find_shadowed_no_coupon_rules.py
"""Detect Magento 2 and Adobe Commerce automatic no-coupon cart price rules
that are shadowed by a coupon based cart price rule, safely.

Magento\\SalesRule\\Model\\ResourceModel\\Rule\\Collection::setValidationFilter()
builds one query to fetch cart price rules valid for the current quote,
joining salesrule_coupon and filtering by coupon_type, sort_order (Priority),
and, once a coupon code is entered, by that code matching rule_coupons.code.
That coupon match has historically been applied as a hard AND rather than an
OR against the no-coupon rule type, so a no-coupon rule is excluded from the
candidate set entirely the instant a coupon rule exists and a code is
entered, not merely failing its conditions. Even when both rules are
fetched, a coupon rule with an equal or higher priority and
stop_rules_processing true stops the no-coupon rule from contributing a
discount.

This reports every shadowed pair by default. It never writes a rule change.
The only gated output, behind DRY_RUN=false and a human confirmation,
prints (does not execute) the PUT /rest/V1/salesRules/{rule_id} payload that
would fix the pair, for manual review. Run on a schedule. 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_shadowed_no_coupon_rules")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def active_cart_price_rules(page_size=100):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "is_active",
        "searchCriteria[filterGroups][0][filters][0][value]": 1,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
        "searchCriteria[pageSize]": page_size,
    }
    return magento_get("/salesRules", params)["items"]


def rule_id_for_coupon_code(code):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "code",
        "searchCriteria[filterGroups][0][filters][0][value]": code,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    items = magento_get("/coupons/search", params)["items"]
    return items[0]["rule_id"] if items else None


def find_shadowed_no_coupon_rules(rules):
    no_coupon = [r for r in rules if r.get("is_active") and r.get("coupon_type") == "NO_COUPON"]
    coupon = [r for r in rules if r.get("is_active") and r.get("coupon_type") != "NO_COUPON"]

    conflicts = []
    for nc in no_coupon:
        for c in coupon:
            shares_website = bool(set(nc.get("website_ids") or []) & set(c.get("website_ids") or []))
            shares_group = bool(set(nc.get("customer_group_ids") or []) & set(c.get("customer_group_ids") or []))
            if not (shares_website and shares_group):
                continue
            if c.get("sort_order", 0) <= nc.get("sort_order", 0) and c.get("stop_rules_processing") is True:
                conflicts.append({
                    "noCouponRuleId": nc["rule_id"],
                    "blockingCouponRuleId": c["rule_id"],
                    "reason": "equal-or-higher-priority coupon rule discards subsequent rules",
                })
    return conflicts


def to_plain_rule(raw):
    return {
        "rule_id": raw["rule_id"],
        "coupon_type": raw.get("coupon_type", "NO_COUPON"),
        "sort_order": raw.get("sort_order", 0),
        "stop_rules_processing": bool(raw.get("stop_rules_processing")),
        "is_active": bool(raw.get("is_active")),
        "website_ids": raw.get("website_ids") or [],
        "customer_group_ids": raw.get("customer_group_ids") or [],
    }


def print_proposed_fix(conflict, rules_by_id):
    coupon_rule = rules_by_id[conflict["blockingCouponRuleId"]]
    no_coupon_rule = rules_by_id[conflict["noCouponRuleId"]]
    lowered_sort_order = no_coupon_rule["sort_order"] - 1
    log.warning(
        "Proposed fix for rule %s shadowing rule %s (not executed, review manually):\n"
        "  PUT /rest/V1/salesRules/%s\n"
        "  { \"rule\": { \"rule_id\": %s, \"sort_order\": %s } }  // was %s\n"
        "  or\n"
        "  { \"rule\": { \"rule_id\": %s, \"stop_rules_processing\": false } }  // was true",
        conflict["blockingCouponRuleId"], conflict["noCouponRuleId"],
        conflict["blockingCouponRuleId"], conflict["blockingCouponRuleId"],
        lowered_sort_order, coupon_rule["sort_order"],
        conflict["blockingCouponRuleId"],
    )


def run():
    raw_rules = active_cart_price_rules()
    rules = [to_plain_rule(r) for r in raw_rules]
    rules_by_id = {r["rule_id"]: r for r in rules}

    conflicts = find_shadowed_no_coupon_rules(rules)
    for conflict in conflicts:
        nc = rules_by_id[conflict["noCouponRuleId"]]
        c = rules_by_id[conflict["blockingCouponRuleId"]]
        log.warning(
            "No-coupon rule %s is shadowed by coupon rule %s: %s "
            "(no_coupon sort_order=%s, coupon sort_order=%s, coupon stop_rules_processing=%s)",
            conflict["noCouponRuleId"], conflict["blockingCouponRuleId"], conflict["reason"],
            nc["sort_order"], c["sort_order"], c["stop_rules_processing"],
        )
        if not DRY_RUN:
            print_proposed_fix(conflict, rules_by_id)

    log.info("Done. %d shadowed no-coupon rule(s) found.", len(conflicts))


if __name__ == "__main__":
    run()
find-shadowed-no-coupon-rules.js
/**
 * Detect Magento 2 and Adobe Commerce automatic no-coupon cart price rules
 * that are shadowed by a coupon based cart price rule, safely.
 *
 * Magento\SalesRule\Model\ResourceModel\Rule\Collection::setValidationFilter()
 * builds one query to fetch cart price rules valid for the current quote,
 * joining salesrule_coupon and filtering by coupon_type, sort_order
 * (Priority), and, once a coupon code is entered, by that code matching
 * rule_coupons.code. That coupon match has historically been applied as a
 * hard AND rather than an OR against the no-coupon rule type, so a
 * no-coupon rule is excluded from the candidate set entirely the instant a
 * coupon rule exists and a code is entered, not merely failing its
 * conditions. Even when both rules are fetched, a coupon rule with an equal
 * or higher priority and stop_rules_processing true stops the no-coupon
 * rule from contributing a discount.
 *
 * This reports every shadowed pair by default. It never writes a rule
 * change. The only gated output, behind DRY_RUN=false and a human
 * confirmation, prints (does not execute) the
 * PUT /rest/V1/salesRules/{rule_id} payload that would fix the pair, for
 * manual review. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/no-coupon-rule-disabled-by-coupon-rule/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function findShadowedNoCouponRules(rules) {
  const noCoupon = rules.filter((r) => r.is_active && r.coupon_type === "NO_COUPON");
  const coupon = rules.filter((r) => r.is_active && r.coupon_type !== "NO_COUPON");

  const conflicts = [];
  for (const nc of noCoupon) {
    for (const c of coupon) {
      const sharesWebsite = (nc.website_ids || []).some((id) => (c.website_ids || []).includes(id));
      const sharesGroup = (nc.customer_group_ids || []).some((id) => (c.customer_group_ids || []).includes(id));
      if (!(sharesWebsite && sharesGroup)) continue;
      if (c.sort_order <= nc.sort_order && c.stop_rules_processing === true) {
        conflicts.push({
          noCouponRuleId: nc.rule_id,
          blockingCouponRuleId: c.rule_id,
          reason: "equal-or-higher-priority coupon rule discards subsequent rules",
        });
      }
    }
  }
  return conflicts;
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function activeCartPriceRules(pageSize = 100) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "is_active",
    "searchCriteria[filterGroups][0][filters][0][value]": 1,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    "searchCriteria[pageSize]": pageSize,
  };
  const data = await magentoGet("/salesRules", params);
  return data.items;
}

async function ruleIdForCouponCode(code) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "code",
    "searchCriteria[filterGroups][0][filters][0][value]": code,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/coupons/search", params);
  const items = data.items || [];
  return items.length ? items[0].rule_id : null;
}

function toPlainRule(raw) {
  return {
    rule_id: raw.rule_id,
    coupon_type: raw.coupon_type || "NO_COUPON",
    sort_order: raw.sort_order || 0,
    stop_rules_processing: Boolean(raw.stop_rules_processing),
    is_active: Boolean(raw.is_active),
    website_ids: raw.website_ids || [],
    customer_group_ids: raw.customer_group_ids || [],
  };
}

function printProposedFix(conflict, rulesById) {
  const couponRule = rulesById.get(conflict.blockingCouponRuleId);
  const noCouponRule = rulesById.get(conflict.noCouponRuleId);
  const loweredSortOrder = noCouponRule.sort_order - 1;
  console.warn(
    `Proposed fix for rule ${conflict.blockingCouponRuleId} shadowing rule ${conflict.noCouponRuleId} (not executed, review manually):\n` +
    `  PUT /rest/V1/salesRules/${conflict.blockingCouponRuleId}\n` +
    `  { "rule": { "rule_id": ${conflict.blockingCouponRuleId}, "sort_order": ${loweredSortOrder} } }  // was ${couponRule.sort_order}\n` +
    `  or\n` +
    `  { "rule": { "rule_id": ${conflict.blockingCouponRuleId}, "stop_rules_processing": false } }  // was true`
  );
}

export async function run() {
  const rawRules = await activeCartPriceRules();
  const rules = rawRules.map(toPlainRule);
  const rulesById = new Map(rules.map((r) => [r.rule_id, r]));

  const conflicts = findShadowedNoCouponRules(rules);
  for (const conflict of conflicts) {
    const nc = rulesById.get(conflict.noCouponRuleId);
    const c = rulesById.get(conflict.blockingCouponRuleId);
    console.warn(
      `No-coupon rule ${conflict.noCouponRuleId} is shadowed by coupon rule ${conflict.blockingCouponRuleId}: ${conflict.reason} ` +
      `(no_coupon sort_order=${nc.sort_order}, coupon sort_order=${c.sort_order}, coupon stop_rules_processing=${c.stop_rules_processing})`
    );
    if (!DRY_RUN) printProposedFix(conflict, rulesById);
  }

  console.log(`Done. ${conflicts.length} shadowed no-coupon rule(s) found.`);
}

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 which rule pair gets reported as shadowed. Because we kept find_shadowed_no_coupon_rules pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture objects and checks the answer.

test_shadowed_rules.py
from find_shadowed_no_coupon_rules import find_shadowed_no_coupon_rules


def rule(**over):
    base = {
        "rule_id": 1,
        "coupon_type": "NO_COUPON",
        "sort_order": 5,
        "stop_rules_processing": False,
        "is_active": True,
        "website_ids": [1],
        "customer_group_ids": [0, 1],
    }
    base.update(over)
    return base


def test_no_conflict_with_differing_priority_and_no_discard():
    no_coupon = rule(rule_id=1, coupon_type="NO_COUPON", sort_order=10)
    coupon = rule(rule_id=2, coupon_type="SPECIFIC_COUPON", sort_order=5, stop_rules_processing=False)
    assert find_shadowed_no_coupon_rules([no_coupon, coupon]) == []


def test_conflict_via_equal_priority_plus_discard():
    no_coupon = rule(rule_id=1, coupon_type="NO_COUPON", sort_order=5)
    coupon = rule(rule_id=2, coupon_type="SPECIFIC_COUPON", sort_order=5, stop_rules_processing=True)
    result = find_shadowed_no_coupon_rules([no_coupon, coupon])
    assert result == [{
        "noCouponRuleId": 1,
        "blockingCouponRuleId": 2,
        "reason": "equal-or-higher-priority coupon rule discards subsequent rules",
    }]


def test_conflict_via_strictly_higher_coupon_rule_priority():
    no_coupon = rule(rule_id=1, coupon_type="NO_COUPON", sort_order=10)
    coupon = rule(rule_id=2, coupon_type="SPECIFIC_AUTOGENERATED", sort_order=3, stop_rules_processing=True)
    result = find_shadowed_no_coupon_rules([no_coupon, coupon])
    assert len(result) == 1
    assert result[0]["blockingCouponRuleId"] == 2


def test_no_conflict_due_to_disjoint_scope():
    no_coupon = rule(rule_id=1, coupon_type="NO_COUPON", sort_order=5, website_ids=[1], customer_group_ids=[0])
    coupon = rule(rule_id=2, coupon_type="SPECIFIC_COUPON", sort_order=5, stop_rules_processing=True,
                  website_ids=[2], customer_group_ids=[1])
    assert find_shadowed_no_coupon_rules([no_coupon, coupon]) == []


def test_inactive_rules_are_ignored():
    no_coupon = rule(rule_id=1, coupon_type="NO_COUPON", sort_order=5, is_active=False)
    coupon = rule(rule_id=2, coupon_type="SPECIFIC_COUPON", sort_order=5, stop_rules_processing=True)
    assert find_shadowed_no_coupon_rules([no_coupon, coupon]) == []
shadowed-rules.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findShadowedNoCouponRules } from "./find-shadowed-no-coupon-rules.js";

const rule = (over = {}) => ({
  rule_id: 1,
  coupon_type: "NO_COUPON",
  sort_order: 5,
  stop_rules_processing: false,
  is_active: true,
  website_ids: [1],
  customer_group_ids: [0, 1],
  ...over,
});

test("no conflict with differing priority and no discard", () => {
  const noCoupon = rule({ rule_id: 1, coupon_type: "NO_COUPON", sort_order: 10 });
  const coupon = rule({ rule_id: 2, coupon_type: "SPECIFIC_COUPON", sort_order: 5, stop_rules_processing: false });
  assert.deepEqual(findShadowedNoCouponRules([noCoupon, coupon]), []);
});

test("conflict via equal priority plus discard", () => {
  const noCoupon = rule({ rule_id: 1, coupon_type: "NO_COUPON", sort_order: 5 });
  const coupon = rule({ rule_id: 2, coupon_type: "SPECIFIC_COUPON", sort_order: 5, stop_rules_processing: true });
  const result = findShadowedNoCouponRules([noCoupon, coupon]);
  assert.deepEqual(result, [{
    noCouponRuleId: 1,
    blockingCouponRuleId: 2,
    reason: "equal-or-higher-priority coupon rule discards subsequent rules",
  }]);
});

test("conflict via strictly higher coupon rule priority", () => {
  const noCoupon = rule({ rule_id: 1, coupon_type: "NO_COUPON", sort_order: 10 });
  const coupon = rule({ rule_id: 2, coupon_type: "SPECIFIC_AUTOGENERATED", sort_order: 3, stop_rules_processing: true });
  const result = findShadowedNoCouponRules([noCoupon, coupon]);
  assert.equal(result.length, 1);
  assert.equal(result[0].blockingCouponRuleId, 2);
});

test("no conflict due to disjoint scope", () => {
  const noCoupon = rule({ rule_id: 1, coupon_type: "NO_COUPON", sort_order: 5, website_ids: [1], customer_group_ids: [0] });
  const coupon = rule({ rule_id: 2, coupon_type: "SPECIFIC_COUPON", sort_order: 5, stop_rules_processing: true, website_ids: [2], customer_group_ids: [1] });
  assert.deepEqual(findShadowedNoCouponRules([noCoupon, coupon]), []);
});

test("inactive rules are ignored", () => {
  const noCoupon = rule({ rule_id: 1, coupon_type: "NO_COUPON", sort_order: 5, is_active: false });
  const coupon = rule({ rule_id: 2, coupon_type: "SPECIFIC_COUPON", sort_order: 5, stop_rules_processing: true });
  assert.deepEqual(findShadowedNoCouponRules([noCoupon, coupon]), []);
});

Case studies

Seasonal sale collision

The welcome discount that vanished every November

A homeware store ran an always-on No Coupon rule that gave every visitor five percent off carts over a threshold. Every November they launched a Black Friday coupon rule with an aggressive priority and Discard Subsequent Rules turned on to keep the sale math simple. Support tickets came in every year asking why the welcome discount disappeared the moment someone typed the sale code.

Running the detection script listed the exact pair, the welcome rule's rule_id and the sale rule's rule_id, along with both sort_order values and the sale rule's stop_rules_processing set to true. Merchandising decided the sale should indeed take priority, so the report simply confirmed the behavior was expected, and the team stopped treating it as a bug every year.

Accidental scope overlap

The loyalty perk silenced by an unrelated rule

A subscription box brand had a no-coupon loyalty perk for repeat customers and, separately, a wholesale coupon rule meant only for a B2B customer group. Someone had accidentally left the wholesale rule's customer group scope as All Groups when creating it, so it also matched retail shoppers, and its higher priority with Discard Subsequent Rules quietly ate the loyalty perk for everyone.

The script flagged the pair immediately because the two rules shared a website and, once the scope mistake was found, a customer group. That pointed straight at the wholesale rule's scope as the actual defect, not the loyalty rule's conditions, and merchandising corrected the customer group instead of guessing at the loyalty rule.

What good looks like

After this runs on a schedule, a no-coupon rule quietly going dark because of a newer coupon rule gets surfaced with the exact rule id pair, both priorities, and the discard flag, instead of surviving as a mystery in a support queue. Merchandising can then decide, with full information, whether the collision is intentional or a scope and priority mistake, and apply the change themselves through the admin. Keep the actual rule edit a human decision, since that is what keeps the script from silently reshaping live promotion stacking.

FAQ

Why did my automatic cart price rule stop applying after I added a coupon rule?

Magento builds one query for valid cart price rules through Rule\Collection::setValidationFilter, and once a shopper enters a coupon code that filter has historically been applied as a hard AND rather than an OR against the no-coupon rule type. That excludes the automatic rule from the candidate set entirely the moment a coupon rule exists and a code is entered, not merely failing its conditions, so it stops contributing a discount even though it is still active.

How do I find which coupon rule is shadowing my automatic rule?

Pull every active cart price rule through GET /rest/V1/salesRules and read each one's coupon_type, sort_order, stop_rules_processing, website_ids, and customer_group_ids. Split the rules into no-coupon and coupon groups, then for every no-coupon rule check whether a coupon rule shares its website and customer group scope and has a sort_order equal to or lower in number than it with stop_rules_processing set to true. That pairing is the shadow. You can confirm it actually breaks the discount by simulating a guest cart, checking totals before and after applying the coupon.

Is it safe for a script to change rule priority or Discard Subsequent Rules automatically?

No. Changing a rule's sort_order or its stop_rules_processing flag changes how promotions stack for every shopper, and that is a merchant decision about live pricing, not something a script should decide on its own. The safe pattern is to report each shadowed pair with its current values, and only under an explicit dry run false plus human confirmation print the exact PUT payload that would fix it as a diff for manual review, never execute it automatically.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Cart Price rules without coupons are not processed when coupon is applied. github.com/magento/magento2/issues/2931
  2. GitHub Issue: Automatic shopping cart price rule does not apply if coupon code is applied. github.com/magento/magento2/issues/8541
  3. GitHub Issue: Incorrect behave Discard subsequent rules and Priority for the cart price rule. github.com/magento/magento2/issues/27202

On the solution:

  1. Adobe Commerce User Guide: Create a cart price rule. experienceleague.adobe.com price rules cart create
  2. Adobe Commerce: Commerce Web API REST API reference. developer.adobe.com/commerce/webapi/rest/reference
  3. Adobe Commerce: REST API Overview, Commerce Web API. developer.adobe.com/commerce/webapi/rest

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, payments, catalog data, or inventory 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 explain a discount that went missing?

If this saved you from a confusing promo collision or a customer wrongly missing a discount, 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 Magento field notes