Diagnostic Customers and Promotions
Promotion applies even though its qualifying rule is not met
A promotion has one job, a rule such as "Grand total over 49.99", spend enough and the discount shows up. Except sometimes it shows up on a cart that, once the discount itself is subtracted, no longer clears that bar. The customer got a discount they should not have gotten, or an order sits with a discount line item that quietly violates its own rule. Here is why Shopware's cart engine lets this happen and a small script that finds the orders where it did.
Shopware 6's cart calculation checks a promotion's rule, for example a CartAmountRule requiring the grand total to be over 49.99, against the cart's running totals, then injects the discount line item in that same pass. It never re-runs rule matching after the discount changes the total, so a rule that was true right before the discount can be false right after it, and the discount stays attached anyway. Shopware confirmed this in shopware/shopware#16236 and closed it as an unstable rule by design, not a bug they will fix. The same gap hits saved orders: editing an order's items later never re-checks whether its frozen promotion line item still matches the rule. Run a small Python or Node.js script that recomputes each order's rule condition against its real line items and flags any order where a promotion discount sits on a cart that fails its own rule. Full code, tests, and sources are below.
The problem in plain words
A promotion's rule and its discount are supposed to agree. The rule decides whether the cart qualifies. The discount is the reward for qualifying. The trouble is both of them look at the same number, the cart's grand total, but the discount changes that number the instant it is applied.
Say the rule is "grand total greater than 49.99" and the discount is a flat 10 off. A cart at 55.00 clears the rule, so Shopware attaches the discount. The cart recalculates to 45.00. That is now below 49.99. The rule that let the discount in is no longer true of the cart that discount produced, but Shopware's calculation pass already moved on, it evaluated the rule once, added the line item, and never asked the question again. The €50 threshold in the reported case was simultaneously above and below the recalculated total, and Shopware's own engineers agreed there is no clean way to pick a single moment to answer that, which is exactly why the report was closed as "unstable rule" rather than fixed.
Why it happens
This is not a random glitch, it is a structural property of how cart calculation works. A few things make it easy to hit and hard to notice:
- The promotion's cart rule, such as
CartAmountRule, is matched against the cart's running totals in the same calculation pass that adds the discount line item, so the check and the change to the thing being checked happen together, not in a stable order. - There is no second pass. Shopware does not re-run rule matching after a discount is injected, so a rule that flips from true to false the instant the discount lands is never caught.
- Shopware's own team looked at this exact case, a 49.99 threshold and a discount that pulls the cart under it, and closed issue #16236 as an unstable rule by design, since the same total is simultaneously above and below the threshold depending on which side of the discount you measure from.
- The same class of bug shows up again on saved orders. An order's line items keep a frozen
payloadsnapshot of the promotion and discount taken at checkout. If someone edits the order afterward, removing an item or changing a quantity, nothing re-validates that the promotion's rule still matches the edited order, so the stale, rule-violating discount line item just sits there. - A related, separately reported issue, #3929, covers promotions with rules that fail to apply consistently, underlining that rule evaluation timing around promotions is a recurring soft spot, not a one-off.
You cannot fix this by re-running the same calculation, because the calculation is the thing that is unstable, not broken in the sense of throwing an error. What you can do is decide, after the fact, which basis the rule is really supposed to be measured against, the goods total before any discount, or the grand total as currently persisted, and then apply the rule's own operator and threshold to that number directly. That comparison is a pure decision, independent of Shopware's internal calculation order, and it is what actually tells you whether a given order's discount is sitting on a cart that violates its own rule.
The fix, as a flow
We do not try to intercept or patch the live cart calculation. The script pulls orders that carry a promotion line item, reads the promotion's actual rule definition, recomputes the total the rule condition targets from the order's own line items, and flags any order where the discount is present but the rule no longer holds. Only with an explicit operator opt-in does it call Shopware's own order recalculation action.
Build it step by step
Get Admin API credentials
Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" # start safe, change to false to recalculate
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" // start safe, change to false to recalculate
Authenticate and talk to the Admin API
A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the searches and for the guarded, opt-in recalculate call.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Find orders carrying a promotion line item
Search order for line items whose type equals promotion, with the lineItems and currency associations, sorted by orderDateTime descending. Page with page and limit, and read total-count-mode:1 so you know when to stop.
def search_orders_with_promotions(token, page=1, limit=200):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "lineItems.type", "value": "promotion"}],
"associations": {"lineItems": {}, "currency": {}},
"sort": [{"field": "orderDateTime", "order": "DESC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def promotion_line_items(order):
return [li for li in (order.get("lineItems") or []) if li.get("type") == "promotion"]
async function searchOrdersWithPromotions(token, page = 1, limit = 200) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "lineItems.type", value: "promotion" }],
associations: { lineItems: {}, currency: {} },
sort: [{ field: "orderDateTime", order: "DESC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
function promotionLineItems(order) {
return (order.lineItems || []).filter((li) => li.type === "promotion");
}
Fetch the promotion's real rule definition
Each promotion line item's payload carries the promotionId and discountId. Search promotion by those ids with the cartRules and orderRules associations to get the actual rule tree, an amount, an operator, and the type of condition, such as CartAmountRule or GoodsPriceRule.
def fetch_promotions(token, promotion_ids):
if not promotion_ids:
return {}
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(promotion_ids)}],
"associations": {"cartRules": {}, "orderRules": {}},
}
data = api("POST", "/api/search/promotion", token, body)
return {row["id"]: row for row in data.get("data", [])}
def extract_rule(promotion):
"""Pulls the first cart rule condition's operator/amount off a promotion.
Real rule payloads nest conditions; this reads the shape the reconciler needs."""
rules = (promotion.get("cartRules") or []) + (promotion.get("orderRules") or [])
for rule in rules:
for condition in (rule.get("payload") or {}).get("conditions", []):
if condition.get("type") in ("cartAmount", "goodsPrice"):
return {
"type": condition["type"],
"operator": condition.get("operator", ">"),
"amount": float(condition.get("amount", 0)),
}
return None
async function fetchPromotions(token, promotionIds) {
if (promotionIds.length === 0) return new Map();
const body = {
filter: [{ type: "equalsAny", field: "id", value: promotionIds }],
associations: { cartRules: {}, orderRules: {} },
};
const data = await api("POST", "/api/search/promotion", token, body);
const map = new Map();
for (const row of data.data || []) map.set(row.id, row);
return map;
}
/**
* Pulls the first cart rule condition's operator/amount off a promotion.
* Real rule payloads nest conditions; this reads the shape the reconciler needs.
*/
function extractRule(promotion) {
const rules = [...(promotion.cartRules || []), ...(promotion.orderRules || [])];
for (const rule of rules) {
const conditions = rule.payload?.conditions || [];
for (const condition of conditions) {
if (condition.type === "cartAmount" || condition.type === "goodsPrice") {
return {
type: condition.type,
operator: condition.operator || ">",
amount: Number(condition.amount || 0),
};
}
}
}
return null;
}
Decide, with one pure function
This is the rule that matters most. Given the order's goods total, its current post-discount grand total, the promotion's rule, and which total basis that rule type scopes against, the function applies the rule's own operator to the right number and returns whether the condition still holds. It takes no order, no promotion object, no network, just the numbers the rule actually cares about, which is what makes it safe to unit test with the exact €50 threshold and €10 discount case from the report.
OPERATORS = {
">": lambda a, b: a > b,
">=": lambda a, b: a >= b,
"<": lambda a, b: a < b,
"<=": lambda a, b: a <= b,
}
def rule_still_matches(order, discount_line_item, rule, evaluate_against):
"""order: {goodsTotal, currentGrandTotal}
discount_line_item: {payload: {discountId}} or None
rule: {type, operator, amount}
evaluate_against: 'preDiscount' | 'postDiscount'
Returns True if the order's chosen total still satisfies the rule's operator/amount."""
if discount_line_item is None or rule is None:
return True
if evaluate_against == "preDiscount":
total = order["goodsTotal"]
else:
total = order["currentGrandTotal"]
op = OPERATORS[rule["operator"]]
return op(total, rule["amount"])
const OPERATORS = {
">": (a, b) => a > b,
">=": (a, b) => a >= b,
"<": (a, b) => a < b,
"<=": (a, b) => a <= b,
};
/**
* order: { goodsTotal, currentGrandTotal }
* discountLineItem: { payload: { discountId } } | null
* rule: { type, operator, amount }
* evaluateAgainst: "preDiscount" | "postDiscount"
* Returns true if the order's chosen total still satisfies the rule's operator/amount.
*/
export function ruleStillMatches(order, discountLineItem, rule, evaluateAgainst) {
if (!discountLineItem || !rule) return true;
const total = evaluateAgainst === "preDiscount" ? order.goodsTotal : order.currentGrandTotal;
return OPERATORS[rule.operator](total, rule.amount);
}
Report by default, recalculate only with an explicit opt-in
Retroactively stripping a discount from a placed order can break invoices and captured payment amounts, so this is not a safe blind auto-fix. Every flagged order is logged as a report row by default. Only when DRY_RUN is explicitly set to false does the script call POST /api/_action/order/{orderId}/recalculate, which re-runs the cart and rule processor against the order's current state and drops or adjusts promotion line items whose rule no longer matches, then re-derives amountTotal and amountNet.
Always start with DRY_RUN=true and read the printed report before doing anything else. Recalculation can also alter tax lines and requires the order to still be in an editable state, not fully shipped or paid and captured in a way that blocks edits, so treat every flagged order as something for a human to confirm before you ever flip the flag to write.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever calls the order recalculation action when the operator has explicitly opted in.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 orders whose promotion discount violates its own rule, and report them, safely.
Shopware 6's cart calculation evaluates a promotion's rule, such as a CartAmountRule requiring
the grand total over 49.99, against the cart's running totals, then injects the discount line
item in the same pass. It never re-runs rule matching after the discount changes the total, so
a rule that was true right before the discount can be false right after it, and the discount
stays attached anyway (shopware/shopware#16236, closed as an unstable rule by design). The same
gap hits saved orders edited after checkout. There is no safe blind auto-fix, so this script
reports every order where the recomputed rule condition fails, and only calls the order
recalculate action behind an explicit opt-in flag. Run on a schedule or on demand. Safe to run
again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_rule_violating_promotions")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EVALUATE_AGAINST = os.environ.get("EVALUATE_AGAINST", "postDiscount")
OPERATORS = {
">": lambda a, b: a > b,
">=": lambda a, b: a >= b,
"<": lambda a, b: a < b,
"<=": lambda a, b: a <= b,
}
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_orders_with_promotions(token, page=1, limit=200):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "lineItems.type", "value": "promotion"}],
"associations": {"lineItems": {}, "currency": {}},
"sort": [{"field": "orderDateTime", "order": "DESC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def promotion_line_items(order):
return [li for li in (order.get("lineItems") or []) if li.get("type") == "promotion"]
def goods_total(order):
"""Sum of non-promotion line items, the pre-discount goods total."""
total = 0.0
for li in (order.get("lineItems") or []):
if li.get("type") == "promotion":
continue
price = li.get("price") or {}
total += float(price.get("totalPrice", 0))
return total
def fetch_promotions(token, promotion_ids):
if not promotion_ids:
return {}
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(promotion_ids)}],
"associations": {"cartRules": {}, "orderRules": {}},
}
data = api("POST", "/api/search/promotion", token, body)
return {row["id"]: row for row in data.get("data", [])}
def extract_rule(promotion):
"""Pulls the first cart rule condition's operator/amount off a promotion.
Real rule payloads nest conditions; this reads the shape the reconciler needs."""
rules = (promotion.get("cartRules") or []) + (promotion.get("orderRules") or [])
for rule in rules:
for condition in (rule.get("payload") or {}).get("conditions", []):
if condition.get("type") in ("cartAmount", "goodsPrice"):
return {
"type": condition["type"],
"operator": condition.get("operator", ">"),
"amount": float(condition.get("amount", 0)),
}
return None
def rule_still_matches(order, discount_line_item, rule, evaluate_against):
"""order: {goodsTotal, currentGrandTotal}
discount_line_item: {payload: {discountId}} or None
rule: {type, operator, amount}
evaluate_against: 'preDiscount' | 'postDiscount'
Returns True if the order's chosen total still satisfies the rule's operator/amount."""
if discount_line_item is None or rule is None:
return True
if evaluate_against == "preDiscount":
total = order["goodsTotal"]
else:
total = order["currentGrandTotal"]
op = OPERATORS[rule["operator"]]
return op(total, rule["amount"])
def recalculate_order(token, order_id):
return api("POST", f"/api/_action/order/{order_id}/recalculate", token, {})
def run():
token = get_token()
flagged = []
page = 1
orders_seen = []
while True:
data = search_orders_with_promotions(token, page=page)
rows = data.get("data", [])
if not rows:
break
orders_seen.extend(rows)
if page * 200 >= data.get("total", 0):
break
page += 1
promotion_ids = set()
for order in orders_seen:
for li in promotion_line_items(order):
payload = li.get("payload") or {}
if payload.get("promotionId"):
promotion_ids.add(payload["promotionId"])
promotions_by_id = fetch_promotions(token, promotion_ids)
for order in orders_seen:
for li in promotion_line_items(order):
payload = li.get("payload") or {}
promotion = promotions_by_id.get(payload.get("promotionId"))
if promotion is None:
continue
rule = extract_rule(promotion)
if rule is None:
continue
order_totals = {
"goodsTotal": goods_total(order),
"currentGrandTotal": float(order.get("amountTotal", 0)),
}
if not rule_still_matches(order_totals, li, rule, EVALUATE_AGAINST):
flagged.append({
"orderId": order["id"],
"orderNumber": order.get("orderNumber"),
"promotionId": payload.get("promotionId"),
"discountId": payload.get("discountId"),
"goodsTotal": order_totals["goodsTotal"],
"currentGrandTotal": order_totals["currentGrandTotal"],
"rule": rule,
})
for item in flagged:
log.warning(
"Order %s: promotion %s violates its own rule (%s %s %.2f). %s",
item["orderNumber"], item["promotionId"], item["rule"]["type"],
item["rule"]["operator"], item["rule"]["amount"],
"dry run, report only" if DRY_RUN else "opted in, recalculating",
)
if not DRY_RUN:
before_total = item["currentGrandTotal"]
result = recalculate_order(token, item["orderId"])
after_total = (result.get("data") or {}).get("price", {}).get("totalPrice", before_total)
log.info("Order %s recalculated. amountTotal %.2f -> %.2f", item["orderNumber"], before_total, after_total)
log.info("Done. %d order(s) flagged with a rule-violating promotion.", len(flagged))
print(json.dumps(flagged, indent=2))
return flagged
if __name__ == "__main__":
run()
/**
* Find Shopware 6 orders whose promotion discount violates its own rule, and report them, safely.
*
* Shopware 6's cart calculation evaluates a promotion's rule, such as a CartAmountRule requiring
* the grand total over 49.99, against the cart's running totals, then injects the discount line
* item in the same pass. It never re-runs rule matching after the discount changes the total, so
* a rule that was true right before the discount can be false right after it, and the discount
* stays attached anyway (shopware/shopware#16236, closed as an unstable rule by design). The same
* gap hits saved orders edited after checkout. There is no safe blind auto-fix, so this script
* reports every order where the recomputed rule condition fails, and only calls the order
* recalculate action behind an explicit opt-in flag. Run on a schedule or on demand. Safe to run
* again and again.
*
* Guide: https://www.allanninal.dev/shopware/promotion-applies-rule-not-met/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EVALUATE_AGAINST = process.env.EVALUATE_AGAINST || "postDiscount";
const OPERATORS = {
">": (a, b) => a > b,
">=": (a, b) => a >= b,
"<": (a, b) => a < b,
"<=": (a, b) => a <= b,
};
/**
* order: { goodsTotal, currentGrandTotal }
* discountLineItem: { payload: { discountId } } | null
* rule: { type, operator, amount }
* evaluateAgainst: "preDiscount" | "postDiscount"
* Returns true if the order's chosen total still satisfies the rule's operator/amount.
*/
export function ruleStillMatches(order, discountLineItem, rule, evaluateAgainst) {
if (!discountLineItem || !rule) return true;
const total = evaluateAgainst === "preDiscount" ? order.goodsTotal : order.currentGrandTotal;
return OPERATORS[rule.operator](total, rule.amount);
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchOrdersWithPromotions(token, page = 1, limit = 200) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "lineItems.type", value: "promotion" }],
associations: { lineItems: {}, currency: {} },
sort: [{ field: "orderDateTime", order: "DESC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
function promotionLineItems(order) {
return (order.lineItems || []).filter((li) => li.type === "promotion");
}
function goodsTotal(order) {
let total = 0;
for (const li of order.lineItems || []) {
if (li.type === "promotion") continue;
total += Number(li.price?.totalPrice || 0);
}
return total;
}
async function fetchPromotions(token, promotionIds) {
if (promotionIds.length === 0) return new Map();
const body = {
filter: [{ type: "equalsAny", field: "id", value: promotionIds }],
associations: { cartRules: {}, orderRules: {} },
};
const data = await api("POST", "/api/search/promotion", token, body);
const map = new Map();
for (const row of data.data || []) map.set(row.id, row);
return map;
}
/**
* Pulls the first cart rule condition's operator/amount off a promotion.
* Real rule payloads nest conditions; this reads the shape the reconciler needs.
*/
function extractRule(promotion) {
const rules = [...(promotion.cartRules || []), ...(promotion.orderRules || [])];
for (const rule of rules) {
const conditions = rule.payload?.conditions || [];
for (const condition of conditions) {
if (condition.type === "cartAmount" || condition.type === "goodsPrice") {
return {
type: condition.type,
operator: condition.operator || ">",
amount: Number(condition.amount || 0),
};
}
}
}
return null;
}
async function recalculateOrder(token, orderId) {
return api("POST", `/api/_action/order/${orderId}/recalculate`, token, {});
}
export async function run() {
const token = await getToken();
let page = 1;
const ordersSeen = [];
while (true) {
const data = await searchOrdersWithPromotions(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
ordersSeen.push(...rows);
if (page * 200 >= (data.total || 0)) break;
page++;
}
const promotionIds = new Set();
for (const order of ordersSeen) {
for (const li of promotionLineItems(order)) {
const promotionId = li.payload?.promotionId;
if (promotionId) promotionIds.add(promotionId);
}
}
const promotionsById = await fetchPromotions(token, [...promotionIds]);
const flagged = [];
for (const order of ordersSeen) {
for (const li of promotionLineItems(order)) {
const promotion = promotionsById.get(li.payload?.promotionId);
if (!promotion) continue;
const rule = extractRule(promotion);
if (!rule) continue;
const orderTotals = {
goodsTotal: goodsTotal(order),
currentGrandTotal: Number(order.amountTotal || 0),
};
if (!ruleStillMatches(orderTotals, li, rule, EVALUATE_AGAINST)) {
flagged.push({
orderId: order.id,
orderNumber: order.orderNumber,
promotionId: li.payload?.promotionId,
discountId: li.payload?.discountId,
goodsTotal: orderTotals.goodsTotal,
currentGrandTotal: orderTotals.currentGrandTotal,
rule,
});
}
}
}
for (const item of flagged) {
console.warn(
`Order ${item.orderNumber}: promotion ${item.promotionId} violates its own rule (${item.rule.type} ${item.rule.operator} ${item.rule.amount.toFixed(2)}). ${DRY_RUN ? "dry run, report only" : "opted in, recalculating"}`
);
if (!DRY_RUN) {
const beforeTotal = item.currentGrandTotal;
const result = await recalculateOrder(token, item.orderId);
const afterTotal = result.data?.price?.totalPrice ?? beforeTotal;
console.log(`Order ${item.orderNumber} recalculated. amountTotal ${beforeTotal.toFixed(2)} -> ${Number(afterTotal).toFixed(2)}`);
}
}
console.log(`Done. ${flagged.length} order(s) flagged with a rule-violating promotion.`);
console.log(JSON.stringify(flagged, null, 2));
return flagged;
}
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 isolates the exact comparison Shopware's cart calculation gets wrong, the operator applied against the goods total versus the current grand total. Because rule_still_matches is pure, no network and no Shopware instance is needed. It just takes plain numbers and checks the answer, including the reported €50 threshold and €10 discount case.
from flag_rule_violating_promotions import rule_still_matches
DISCOUNT = {"payload": {"discountId": "discount-1"}}
CART_AMOUNT_RULE = {"type": "cartAmount", "operator": ">", "amount": 49.99}
def test_no_discount_line_item_always_matches():
order = {"goodsTotal": 20.0, "currentGrandTotal": 20.0}
assert rule_still_matches(order, None, CART_AMOUNT_RULE, "postDiscount") is True
def test_no_rule_always_matches():
order = {"goodsTotal": 20.0, "currentGrandTotal": 20.0}
assert rule_still_matches(order, DISCOUNT, None, "postDiscount") is True
def test_reported_case_fails_post_discount():
# Cart was 55.00 pre-discount (clears 49.99), a 10.00 discount drops it to 45.00.
order = {"goodsTotal": 55.00, "currentGrandTotal": 45.00}
assert rule_still_matches(order, DISCOUNT, CART_AMOUNT_RULE, "postDiscount") is False
def test_reported_case_passes_pre_discount():
order = {"goodsTotal": 55.00, "currentGrandTotal": 45.00}
assert rule_still_matches(order, DISCOUNT, CART_AMOUNT_RULE, "preDiscount") is True
def test_matches_when_post_discount_total_still_clears_threshold():
# 100.00 goods, 10.00 discount, still 90.00 post-discount, well above 49.99.
order = {"goodsTotal": 100.00, "currentGrandTotal": 90.00}
assert rule_still_matches(order, DISCOUNT, CART_AMOUNT_RULE, "postDiscount") is True
def test_operator_gte_boundary():
rule = {"type": "cartAmount", "operator": ">=", "amount": 45.00}
order = {"goodsTotal": 55.00, "currentGrandTotal": 45.00}
assert rule_still_matches(order, DISCOUNT, rule, "postDiscount") is True
def test_operator_lt_for_goods_price_rule():
rule = {"type": "goodsPrice", "operator": "<", "amount": 50.00}
order = {"goodsTotal": 55.00, "currentGrandTotal": 45.00}
assert rule_still_matches(order, DISCOUNT, rule, "preDiscount") is False
assert rule_still_matches(order, DISCOUNT, rule, "postDiscount") is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { ruleStillMatches } from "./flag-rule-violating-promotions.js";
const DISCOUNT = { payload: { discountId: "discount-1" } };
const CART_AMOUNT_RULE = { type: "cartAmount", operator: ">", amount: 49.99 };
test("no discount line item always matches", () => {
const order = { goodsTotal: 20.0, currentGrandTotal: 20.0 };
assert.equal(ruleStillMatches(order, null, CART_AMOUNT_RULE, "postDiscount"), true);
});
test("no rule always matches", () => {
const order = { goodsTotal: 20.0, currentGrandTotal: 20.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, null, "postDiscount"), true);
});
test("reported case fails post discount", () => {
// Cart was 55.00 pre-discount (clears 49.99), a 10.00 discount drops it to 45.00.
const order = { goodsTotal: 55.0, currentGrandTotal: 45.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, CART_AMOUNT_RULE, "postDiscount"), false);
});
test("reported case passes pre discount", () => {
const order = { goodsTotal: 55.0, currentGrandTotal: 45.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, CART_AMOUNT_RULE, "preDiscount"), true);
});
test("matches when post discount total still clears threshold", () => {
// 100.00 goods, 10.00 discount, still 90.00 post-discount, well above 49.99.
const order = { goodsTotal: 100.0, currentGrandTotal: 90.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, CART_AMOUNT_RULE, "postDiscount"), true);
});
test("operator gte boundary", () => {
const rule = { type: "cartAmount", operator: ">=", amount: 45.0 };
const order = { goodsTotal: 55.0, currentGrandTotal: 45.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, rule, "postDiscount"), true);
});
test("operator lt for goods price rule", () => {
const rule = { type: "goodsPrice", operator: "<", amount: 50.0 };
const order = { goodsTotal: 55.0, currentGrandTotal: 45.0 };
assert.equal(ruleStillMatches(order, DISCOUNT, rule, "preDiscount"), false);
assert.equal(ruleStillMatches(order, DISCOUNT, rule, "postDiscount"), true);
});
Case studies
A free-shipping style promotion undercut its own minimum
A homeware store ran a "spend over 49.99, take 10 off" promotion during a sale week. Customers with carts sitting right at the edge, 52 to 58 euros, got the discount and ended up checking out at 42 to 48 euros, comfortably under the store's own advertised minimum. Nobody noticed until finance flagged that a chunk of "qualifying" orders were, after the discount, clearly not qualifying carts.
Running the script against the order history recomputed each order's goods total and matched it against the promotion's real CartAmountRule. It reported every order where the post-discount total fell under 49.99, giving finance an exact list instead of a guess, and the promotion's rule was tightened for the next sale to require goods total, not grand total.
A support agent removed an item and the discount stayed
A customer support agent removed one line item from a placed order at the customer's request, a normal edit. The order had qualified for a promotion at checkout, but after the edit, the remaining goods no longer cleared the promotion's threshold. Nothing in the edit flow re-checked the promotion, so the order kept its discount, and the customer effectively received more discount than the current cart justified.
The script's report surfaced the order because its recomputed goods total, using the current line items, failed the rule the discount depended on. The team confirmed it in the Administration, then opted in to call the recalculate action, which dropped the stale discount and re-derived the order's totals cleanly.
After this runs on a schedule, an order whose promotion discount no longer matches its own rule shows up in a report with the exact recomputed totals next to it, not a guess. Nothing gets recalculated automatically, and an order whose rule still holds is left completely untouched. Only when a human reviews the report and opts in does the script call the recalculate action, and even then Shopware's own cart and rule processor decides the new state, never a manual overwrite of the total.
FAQ
Why does a Shopware 6 promotion stay applied when the cart no longer meets its rule?
Shopware's cart calculation evaluates the promotion's rule, such as a grand total over 49.99, against the cart's running totals and then injects the discount line item in the same pass. It does not re-run rule matching after the discount is added, so a rule that was only true before the discount stays attached even though the post-discount total no longer satisfies it. Shopware has confirmed this in shopware/shopware#16236 and closed it as an unstable rule by design rather than a bug to fix.
Can I just delete the discount line item from orders that violate the rule?
Not directly and not blindly. Stripping a discount from a placed order can leave invoices and captured payment amounts out of sync with the order total. The safer path is to flag the order first, and only under an explicit operator opt-in call the order recalculate action, which re-runs the cart and rule processor against the current order state and will drop or adjust a promotion whose rule no longer matches.
How do I recalculate an order so a stale promotion is removed properly?
Call POST /api/_action/order/{orderId}/recalculate. This re-runs Shopware's cart and rule processor against the order's current line items and re-derives amountTotal and amountNet, dropping or adjusting promotion line items whose rule no longer matches. The order must still be in an editable state, so diff the recalculated cart against the persisted order and get a confirmation before you call it, since it can also alter tax lines.
Related field notes
Citations
On the problem:
- Shopware GitHub Issues: Promotion is applied even though the specified rule is not met. github.com/shopware/shopware/issues/16236
- Shopware GitHub Issues: Promotions with rules cannot apply. github.com/shopware/shopware/issues/3929
- Shopware 6 Documentation: Rule Builder. docs.shopware.com settings/rules
On the solution:
- Shopware Admin API Reference: Order Management. shopware.stoplight.io admin-api order-management
- Shopware Developer Documentation: Rules. developer.shopware.com concepts/framework/rules
- Shopware Developer Documentation: Search Criteria. developer.shopware.com search-criteria
Stuck on a tricky one?
If you have a problem in Shopware order states, stock, message queue, or data integrity 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 catch a stale discount?
If this saved you from a promotion quietly undercutting its own minimum, 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