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.
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.
Why it happens
Magento\SalesRule\Model\ResourceModel\Rule\Collection::setValidationFilter()builds the query that decides which cart price rules are even candidates for a quote. It joinssalesrule_coupon, filters bycoupon_typeandsort_order, and once a coupon code is present, filters by that code matchingrule_coupons.code. That match has historically been applied as an AND rather than an OR againstmain_table.coupon_type = 1(no coupon), so a no-coupon rule is excluded outright rather than merely failing to match, the instant a coupon rule exists and a code is entered.- Even on versions where both rules are correctly fetched,
sort_order(Priority) decides evaluation order, lower numbers run first. A coupon rule with an equal or lowersort_orderthan the no-coupon rule runs at the same time or earlier. stop_rules_processing(Discard Subsequent Rulesset to yes) on that coupon rule stops every rule after it in priority order from contributing a discount, including a no-coupon rule that would otherwise have applied fine on its own.- Merchants usually build the no-coupon rule first when there is only one promotion running, see it work, then add a coupon rule for a sale weeks later without revisiting the no-coupon rule's priority, so the two rules end up colliding by accident rather than by design.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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"]
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;
}
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.
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
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;
}
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.
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
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;
}
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.
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.
"""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()
/**
* 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.
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]) == []
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
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.
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.
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:
- GitHub Issue: Cart Price rules without coupons are not processed when coupon is applied. github.com/magento/magento2/issues/2931
- GitHub Issue: Automatic shopping cart price rule does not apply if coupon code is applied. github.com/magento/magento2/issues/8541
- GitHub Issue: Incorrect behave Discard subsequent rules and Priority for the cart price rule. github.com/magento/magento2/issues/27202
On the solution:
- Adobe Commerce User Guide: Create a cart price rule. experienceleague.adobe.com price rules cart create
- Adobe Commerce: Commerce Web API REST API reference. developer.adobe.com/commerce/webapi/rest/reference
- 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.
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