Diagnostic Customers and Promotions
Promotion maximum usage limit not enforced
A promotion is set to a hard cap, say two hundred redemptions total, or one per customer. Weeks later someone notices order after order still carrying that code well past the cap. Nothing crashed and no error was logged. Shopware was checking the limit against a number it had not caught up on yet. Here is why the cap gets checked against a stale cached count instead of the real one, and a script that finds every promotion that actually went over.
Shopware checks a promotion's maxRedemptionsGlobal and maxRedemptionsPerCustomer against promotion.orderCount and orderCountPerCustomer, cached counters that PromotionRedemptionUpdater recomputes with COUNT(DISTINCT order_line_item.order_id) grouped by promotion id, not a real-time transactional check at cart validation time. When a promotion has more than one discount or set group, each group can independently pass its own rule and add a line item within the same checkout, and known defects in that discount-group path (shopware/shopware issues #5417, #13644, #3885) let the usage check get bypassed or miscounted. Authenticate with POST /api/oauth/token, search active promotions with a finite limit and more than one discount or set group, pull every redeeming order-line-item, and recompute the true count in script memory the same way Shopware's own indexer does. Report anything over the configured maximum, and only deactivate the promotion once that is confirmed, gated by DRY_RUN. Full code, tests, and sources are below.
The problem in plain words
A promotion's redemption limit sounds like something that should be checked the moment an order is placed, the same way stock is checked. It is not. Shopware tracks how many times a promotion has been used with two fields on the promotion entity itself, orderCount and orderCountPerCustomer. Those fields are not live. They are filled in by a background process, PromotionRedemptionUpdater, that runs a count query over every order line item tied to that promotion and writes the result back after the fact.
So when the cart validator checks whether a promotion is still allowed, it is really asking "what did the last count say," not "how many times has this actually been used right now." Most of the time that gap is invisible, because one checkout at a time and one discount at a time keeps the cached number close enough to reality. The gap becomes a real over-redemption when a promotion has more than one discount or set group configured, because each group can independently qualify and add its own promotion line item to the same cart, and the known defects in that evaluation path mean the usage check on one or more of those groups can be skipped or counted wrong entirely.
Why it happens
The gap is not one bug, it is a real-time decision resting on a number that is only ever eventually correct. A few things line up to produce an actual over-redemption:
PromotionRedemptionUpdatercomputesorderCountandorderCountPerCustomerwithCOUNT(DISTINCT order_line_item.order_id)grouped bypromotion_id, and writes it back through the DAL indexer after an order exists, not during the checkout that creates it. The cart validator has nothing more current to check against.- A promotion configured with more than one
promotion_discountorpromotion_setgrouplets each group qualify on its own rule independently, and Shopware's own tracker has documented defects in that discount-group evaluation path (shopware/shopware issues #5417, #13644, and #3885) where the usage check on one group gets bypassed or the wrong scope gets counted entirely. - Because the cache only updates after order placement, two checkouts started at nearly the same time can each read the same, still-valid cached count and both pass validation before either order exists to update it, so the same code can be used more times than the limit even without any discount-group defect at all.
- None of this throws an error or writes a log line a store owner would notice. The order simply goes through with the promotion applied, and the only trace is a redemption count that quietly climbs past the number configured in the Admin.
See the citations at the end for the exact GitHub issues describing this discount-group behavior and the source of the redemption updater itself.
You cannot trust promotion.orderCount to tell you whether a promotion is truly over its limit, because it is exactly the field the defect leaves stale. The reliable signal is to recompute the count yourself from the source Shopware's own updater reads: distinct orders carrying a promotion type order-line-item for that promotion id. Any promotion configured with more than one discount or set group and a finite maxRedemptionsGlobal is worth checking even before you see a breach, since that configuration is exactly what the known defects target.
The fix, as a flow
We never touch a live order or rewrite the cached counter. The script searches active promotions with a finite redemption limit, flags the ones with more than one discount or set group as the configuration the known defects target, pulls every redeeming line item for those, and recomputes the true redemption count with a pure function that mirrors Shopware's own SQL. Only a confirmed over-redemption gets a write, and that write is limited to deactivating the promotion.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false to write
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for searching promotions, searching line items, and patching the flagged promotion later.
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_post(path, token, body=None):
r = requests.post(
f"{SHOPWARE_URL}{path}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body or {},
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" },
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 apiPost(path, token, body = {}) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Search promotions with a finite limit, then pull their redeeming line items
Search promotion filtered to active: true, with associations for discounts, setgroups, and individualCodes. Keep only promotions where maxRedemptionsGlobal or maxRedemptionsPerCustomer is set, and note how many discount or set groups each one has, since more than one is the configuration the known defects target. For each flagged promotion, search order-line-item filtered by promotionId and type: promotion, joined to order.orderCustomer, to get every redeeming order and the customer behind it.
def page_flagged_promotions(token, page):
body = {
"page": page,
"limit": 500,
"filter": [{"type": "equals", "field": "active", "value": True}],
"associations": {"discounts": {}, "setgroups": {}, "individualCodes": {}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/promotion",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def redemptions_for_promotion(token, promotion_id):
body = {
"page": 1,
"limit": 500,
"filter": [
{"type": "equals", "field": "promotionId", "value": promotion_id},
{"type": "equals", "field": "type", "value": "promotion"},
],
"associations": {"order": {"associations": {"orderCustomer": {}}}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-line-item",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
async function pageFlaggedPromotions(token, page) {
const body = {
page,
limit: 500,
filter: [{ type: "equals", field: "active", value: true }],
associations: { discounts: {}, setgroups: {}, individualCodes: {} },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/promotion`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on promotion search`);
const data = await res.json();
return data.data;
}
async function redemptionsForPromotion(token, promotionId) {
const body = {
page: 1,
limit: 500,
filter: [
{ type: "equals", field: "promotionId", value: promotionId },
{ type: "equals", field: "type", value: "promotion" },
],
associations: { order: { associations: { orderCustomer: {} } } },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
const data = await res.json();
return data.data;
}
Decide, with one pure function
Keep the decision in its own function that takes the promotion's configured limits and its list of redemptions, plain orderId and customerId pairs, and returns a verdict. It dedupes by orderId, matching Shopware's own COUNT(DISTINCT order_line_item.order_id), to get the true global count, groups distinct order ids by customer for the per-customer counts, and flags over_global, over_per_customer, or an informational multi_group_risk when the counts still look fine but the promotion has more than one discount group and a finite global max, the exact combination the known defects target.
def evaluate_promotion_usage(promotion, redemptions):
orders_by_customer = {}
all_order_ids = set()
for r in redemptions:
all_order_ids.add(r["orderId"])
orders_by_customer.setdefault(r["customerId"], set()).add(r["orderId"])
actual_global_count = len(all_order_ids)
per_customer_counts = {c: len(ids) for c, ids in orders_by_customer.items()}
max_global = promotion.get("maxRedemptionsGlobal")
max_per_customer = promotion.get("maxRedemptionsPerCustomer")
discount_group_count = promotion.get("discountGroupCount", 0)
over_global = max_global is not None and actual_global_count > max_global
offending_customer_ids = sorted(
c for c, n in per_customer_counts.items()
if max_per_customer is not None and n > max_per_customer
)
if over_global:
status = "over_global"
elif offending_customer_ids:
status = "over_per_customer"
elif discount_group_count > 1 and max_global is not None:
status = "multi_group_risk"
else:
status = "ok"
return {
"status": status,
"actualGlobalCount": actual_global_count,
"perCustomerCounts": per_customer_counts,
"offendingCustomerIds": offending_customer_ids,
}
export function evaluatePromotionUsage(promotion, redemptions) {
const ordersByCustomer = new Map();
const allOrderIds = new Set();
for (const r of redemptions) {
allOrderIds.add(r.orderId);
if (!ordersByCustomer.has(r.customerId)) ordersByCustomer.set(r.customerId, new Set());
ordersByCustomer.get(r.customerId).add(r.orderId);
}
const actualGlobalCount = allOrderIds.size;
const perCustomerCounts = {};
for (const [c, ids] of ordersByCustomer) perCustomerCounts[c] = ids.size;
const maxGlobal = promotion.maxRedemptionsGlobal;
const maxPerCustomer = promotion.maxRedemptionsPerCustomer;
const discountGroupCount = promotion.discountGroupCount || 0;
const overGlobal = maxGlobal != null && actualGlobalCount > maxGlobal;
const offendingCustomerIds = Object.entries(perCustomerCounts)
.filter(([, n]) => maxPerCustomer != null && n > maxPerCustomer)
.map(([c]) => c)
.sort();
let status;
if (overGlobal) status = "over_global";
else if (offendingCustomerIds.length) status = "over_per_customer";
else if (discountGroupCount > 1 && maxGlobal != null) status = "multi_group_risk";
else status = "ok";
return { status, actualGlobalCount, perCustomerCounts, offendingCustomerIds };
}
Deactivate only a confirmed over-redemption, never touch orders
There is no safe automated fix for orders that already went through with an over-applied promotion, since cancelling or clawing back a discount is a business call. The only write is PATCH /api/promotion/{id} to set active: false once actualGlobalCount is confirmed to exceed maxRedemptionsGlobal, gated behind DRY_RUN. Never write to order-line-item, order, or promotion.orderCount, since Shopware's own indexer will just overwrite those on its next pass.
def deactivate_promotion(token, promotion_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/promotion/{promotion_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"active": False},
timeout=30,
)
r.raise_for_status()
async function deactivatePromotion(token, promotionId) {
const res = await fetch(`${SHOPWARE_URL}/api/promotion/${promotionId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ active: false }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on promotion patch`);
}
Wire it together with a dry run guard
The loop ties every piece together. Page every active promotion with a finite limit, pull its redeeming line items, run the pure evaluation function, and log every flagged promotion with its status, actual count, cached orderCount, and the offending order ids for manual review. Only when DRY_RUN is off does it deactivate a confirmed over-redeemed promotion. A multi_group_risk verdict is logged but never triggers a write, since nothing has actually breached yet.
Never write to order-line-item, order, or promotion.orderCount to fix this. Those orders and invoices are already real, and the cached counter is derived, so Shopware's own indexer will overwrite anything you write there. Start with DRY_RUN=true, review every flagged promotion and its offending order ids, and only let the script deactivate a promotion once a human has confirmed the actual count is truly over the configured maximum.
The full code
Here is the complete script in one file for each language. It authenticates, pages every active promotion with a finite redemption limit, pulls the true redeeming line items, evaluates each with the pure function, reports every flagged promotion, and deactivates a confirmed over-redemption, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Detect Shopware 6 promotions that have exceeded their configured maximum usage
limit, without ever writing to the derived order-line-item or promotion.orderCount
data.
Shopware enforces a promotion's maxRedemptionsGlobal and maxRedemptionsPerCustomer
against a cached counter, promotion.orderCount and orderCountPerCustomer, that
PromotionRedemptionUpdater recomputes with a COUNT(DISTINCT order_line_item.order_id)
grouped by promotion_id, not a real-time transactional check at cart validation time.
When a promotion has more than one discount or set group (promotion_discount /
promotion_setgroup), each qualifying group can independently satisfy its own rule and
add a promotion line item to the cart in the same checkout, and known defects in the
discount-group evaluation path (shopware/shopware #5417, #13644, #3885) cause the cart
validator's usage check to be bypassed or miscounted when more than one discount group
is configured with a finite maxRedemptionsGlobal or maxRedemptionsPerCustomer. Because
the redemption count is cached and only reindexed asynchronously after order
placement, concurrent checkouts using the same code can also both pass validation
before the counter updates, compounding the multi-group miscount into usage beyond
the configured maximum.
This script searches active promotions with a finite maxRedemptionsGlobal or
maxRedemptionsPerCustomer, pulls every redeeming order-line-item for a flagged
promotion, independently recomputes the actual redemption counts in script memory
(mirroring Shopware's own PromotionRedemptionUpdater SQL), and reports any promotion
whose actual usage has gone over its configured maximum, or whose actual counts drift
from the cached orderCount fields. It never modifies order-line-item, order, or
promotion.orderCount, since those are derived and Shopware's own indexer will
recompute them on the next DAL pass. The one safe automated write, gated by DRY_RUN,
PATCHes an over-redeemed promotion to active: false to stop further over-redemption,
logging the offending order ids for human review. Run on demand. 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("evaluate_promotion_usage")
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"
PAGE_LIMIT = 500
def evaluate_promotion_usage(promotion, redemptions):
"""Pure decision. No I/O.
promotion: {id, maxRedemptionsGlobal, maxRedemptionsPerCustomer, discountGroupCount}
redemptions: list of {orderId, customerId}
Dedupes redemptions by orderId (COUNT DISTINCT order_id, matching Shopware's own
indexer semantics) to get actualGlobalCount, groups distinct orderIds by
customerId for perCustomerCounts, flags 'over_global' if maxRedemptionsGlobal is
set and actualGlobalCount exceeds it, flags 'over_per_customer' if any
perCustomerCounts[c] exceeds maxRedemptionsPerCustomer, flags 'multi_group_risk'
(informational, no breach yet) if discountGroupCount > 1 and maxRedemptionsGlobal
is set, even when counts currently look fine, since drift can appear after the
next concurrent checkout. Returns a plain verdict object.
"""
orders_by_customer = {}
all_order_ids = set()
for r in redemptions:
order_id = r["orderId"]
customer_id = r["customerId"]
all_order_ids.add(order_id)
orders_by_customer.setdefault(customer_id, set()).add(order_id)
actual_global_count = len(all_order_ids)
per_customer_counts = {
customer_id: len(order_ids)
for customer_id, order_ids in orders_by_customer.items()
}
max_global = promotion.get("maxRedemptionsGlobal")
max_per_customer = promotion.get("maxRedemptionsPerCustomer")
discount_group_count = promotion.get("discountGroupCount", 0)
over_global = max_global is not None and actual_global_count > max_global
offending_customer_ids = []
if max_per_customer is not None:
offending_customer_ids = sorted(
customer_id
for customer_id, count in per_customer_counts.items()
if count > max_per_customer
)
over_per_customer = len(offending_customer_ids) > 0
if over_global:
status = "over_global"
elif over_per_customer:
status = "over_per_customer"
elif discount_group_count > 1 and max_global is not None:
status = "multi_group_risk"
else:
status = "ok"
return {
"status": status,
"actualGlobalCount": actual_global_count,
"perCustomerCounts": per_customer_counts,
"offendingCustomerIds": offending_customer_ids,
}
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},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def page_flagged_promotions(token, page):
body = {
"page": page,
"limit": PAGE_LIMIT,
"filter": [{"type": "equals", "field": "active", "value": True}],
"associations": {"discounts": {}, "setgroups": {}, "individualCodes": {}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/promotion",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def redemptions_for_promotion(token, promotion_id):
body = {
"page": 1,
"limit": PAGE_LIMIT,
"filter": [
{"type": "equals", "field": "promotionId", "value": promotion_id},
{"type": "equals", "field": "type", "value": "promotion"},
],
"associations": {"order": {"associations": {"orderCustomer": {}}}},
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-line-item",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
rows = r.json()["data"]
redemptions = []
for row in rows:
order = row.get("order") or {}
order_customer = order.get("orderCustomer") or {}
redemptions.append({
"orderId": row["orderId"],
"customerId": order_customer.get("customerId"),
"orderNumber": order.get("orderNumber"),
})
return redemptions
def all_promotions_with_a_finite_limit(token):
page = 1
while True:
rows = page_flagged_promotions(token, page)
for row in rows:
max_global = row.get("maxRedemptionsGlobal")
max_per_customer = row.get("maxRedemptionsPerCustomer")
if max_global is None and max_per_customer is None:
continue
discounts = (row.get("discounts") or [])
setgroups = (row.get("setgroups") or [])
discount_group_count = max(len(discounts), len(setgroups))
yield {
"id": row["id"],
"maxRedemptionsGlobal": max_global,
"maxRedemptionsPerCustomer": max_per_customer,
"discountGroupCount": discount_group_count,
"orderCount": row.get("orderCount"),
"orderCountPerCustomer": row.get("orderCountPerCustomer"),
}
if len(rows) < PAGE_LIMIT:
return
page += 1
def deactivate_promotion(token, promotion_id):
r = requests.patch(
f"{SHOPWARE_URL}/api/promotion/{promotion_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json={"active": False},
timeout=30,
)
r.raise_for_status()
def run():
token = get_token()
promotions = list(all_promotions_with_a_finite_limit(token))
if not promotions:
log.info("Done. 0 promotion(s) with a finite redemption limit found.")
return
flagged_over = 0
for promotion in promotions:
redemptions = redemptions_for_promotion(token, promotion["id"])
verdict = evaluate_promotion_usage(promotion, redemptions)
if verdict["status"] == "ok":
continue
offending_order_ids = sorted({r["orderId"] for r in redemptions})
log.warning(
"Promotion %s status=%s actualGlobalCount=%d maxRedemptionsGlobal=%s "
"cachedOrderCount=%s discountGroupCount=%d offendingCustomerIds=%s",
promotion["id"], verdict["status"], verdict["actualGlobalCount"],
promotion["maxRedemptionsGlobal"], promotion["orderCount"],
promotion["discountGroupCount"], verdict["offendingCustomerIds"],
)
log.warning("Offending order id(s) for review: %s", offending_order_ids)
if verdict["status"] in ("over_global", "over_per_customer"):
log.warning(
"%s promotion %s inactive to stop further over-redemption",
"Would deactivate" if DRY_RUN else "Deactivating", promotion["id"],
)
if not DRY_RUN:
deactivate_promotion(token, promotion["id"])
flagged_over += 1
log.info(
"Done. %d promotion(s) checked, %d over-redeemed promotion(s) %s.",
len(promotions), flagged_over, "to deactivate" if DRY_RUN else "deactivated",
)
if __name__ == "__main__":
run()
/**
* Detect Shopware 6 promotions that have exceeded their configured maximum usage
* limit, without ever writing to the derived order-line-item or promotion.orderCount
* data.
*
* Shopware enforces a promotion's maxRedemptionsGlobal and maxRedemptionsPerCustomer
* against a cached counter, promotion.orderCount and orderCountPerCustomer, that
* PromotionRedemptionUpdater recomputes with a COUNT(DISTINCT order_line_item.order_id)
* grouped by promotion_id, not a real-time transactional check at cart validation
* time. When a promotion has more than one discount or set group (promotion_discount
* / promotion_setgroup), each qualifying group can independently satisfy its own rule
* and add a promotion line item to the cart in the same checkout, and known defects
* in the discount-group evaluation path (shopware/shopware #5417, #13644, #3885)
* cause the cart validator's usage check to be bypassed or miscounted when more than
* one discount group is configured with a finite maxRedemptionsGlobal or
* maxRedemptionsPerCustomer. Because the redemption count is cached and only
* reindexed asynchronously after order placement, concurrent checkouts using the
* same code can also both pass validation before the counter updates, compounding
* the multi-group miscount into usage beyond the configured maximum.
*
* This searches active promotions with a finite maxRedemptionsGlobal or
* maxRedemptionsPerCustomer, pulls every redeeming order-line-item for a flagged
* promotion, independently recomputes the actual redemption counts in script memory
* (mirroring Shopware's own PromotionRedemptionUpdater SQL), and reports any
* promotion whose actual usage has gone over its configured maximum, or whose actual
* counts drift from the cached orderCount fields. It never modifies
* order-line-item, order, or promotion.orderCount, since those are derived and
* Shopware's own indexer will recompute them on the next DAL pass. The one safe
* automated write, gated by DRY_RUN, PATCHes an over-redeemed promotion to
* active: false to stop further over-redemption, logging the offending order ids
* for human review. Run on demand.
*
* Guide: https://www.allanninal.dev/shopware/promotion-max-usage-not-enforced/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAGE_LIMIT = 500;
export function evaluatePromotionUsage(promotion, redemptions) {
const ordersByCustomer = new Map();
const allOrderIds = new Set();
for (const r of redemptions) {
allOrderIds.add(r.orderId);
if (!ordersByCustomer.has(r.customerId)) ordersByCustomer.set(r.customerId, new Set());
ordersByCustomer.get(r.customerId).add(r.orderId);
}
const actualGlobalCount = allOrderIds.size;
const perCustomerCounts = {};
for (const [customerId, orderIds] of ordersByCustomer) {
perCustomerCounts[customerId] = orderIds.size;
}
const maxGlobal = promotion.maxRedemptionsGlobal;
const maxPerCustomer = promotion.maxRedemptionsPerCustomer;
const discountGroupCount = promotion.discountGroupCount || 0;
const overGlobal = maxGlobal != null && actualGlobalCount > maxGlobal;
let offendingCustomerIds = [];
if (maxPerCustomer != null) {
offendingCustomerIds = Object.entries(perCustomerCounts)
.filter(([, count]) => count > maxPerCustomer)
.map(([customerId]) => customerId)
.sort();
}
const overPerCustomer = offendingCustomerIds.length > 0;
let status;
if (overGlobal) status = "over_global";
else if (overPerCustomer) status = "over_per_customer";
else if (discountGroupCount > 1 && maxGlobal != null) status = "multi_group_risk";
else status = "ok";
return { status, actualGlobalCount, perCustomerCounts, offendingCustomerIds };
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "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 pageFlaggedPromotions(token, page) {
const body = {
page,
limit: PAGE_LIMIT,
filter: [{ type: "equals", field: "active", value: true }],
associations: { discounts: {}, setgroups: {}, individualCodes: {} },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/promotion`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on promotion search`);
const data = await res.json();
return data.data;
}
async function redemptionsForPromotion(token, promotionId) {
const body = {
page: 1,
limit: PAGE_LIMIT,
filter: [
{ type: "equals", field: "promotionId", value: promotionId },
{ type: "equals", field: "type", value: "promotion" },
],
associations: { order: { associations: { orderCustomer: {} } } },
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-line-item`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on order-line-item search`);
const data = await res.json();
return data.data.map((row) => {
const order = row.order || {};
const orderCustomer = order.orderCustomer || {};
return {
orderId: row.orderId,
customerId: orderCustomer.customerId ?? null,
orderNumber: order.orderNumber ?? null,
};
});
}
async function* allPromotionsWithAFiniteLimit(token) {
let page = 1;
while (true) {
const rows = await pageFlaggedPromotions(token, page);
for (const row of rows) {
const maxGlobal = row.maxRedemptionsGlobal ?? null;
const maxPerCustomer = row.maxRedemptionsPerCustomer ?? null;
if (maxGlobal == null && maxPerCustomer == null) continue;
const discounts = row.discounts || [];
const setgroups = row.setgroups || [];
const discountGroupCount = Math.max(discounts.length, setgroups.length);
yield {
id: row.id,
maxRedemptionsGlobal: maxGlobal,
maxRedemptionsPerCustomer: maxPerCustomer,
discountGroupCount,
orderCount: row.orderCount ?? null,
orderCountPerCustomer: row.orderCountPerCustomer ?? null,
};
}
if (rows.length < PAGE_LIMIT) return;
page++;
}
}
async function deactivatePromotion(token, promotionId) {
const res = await fetch(`${SHOPWARE_URL}/api/promotion/${promotionId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ active: false }),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on promotion patch`);
}
export async function run() {
const token = await getToken();
const promotions = [];
for await (const p of allPromotionsWithAFiniteLimit(token)) promotions.push(p);
if (!promotions.length) {
console.log("Done. 0 promotion(s) with a finite redemption limit found.");
return;
}
let flaggedOver = 0;
for (const promotion of promotions) {
const redemptions = await redemptionsForPromotion(token, promotion.id);
const verdict = evaluatePromotionUsage(promotion, redemptions);
if (verdict.status === "ok") continue;
const offendingOrderIds = [...new Set(redemptions.map((r) => r.orderId))].sort();
console.warn(
`Promotion ${promotion.id} status=${verdict.status} actualGlobalCount=${verdict.actualGlobalCount} ` +
`maxRedemptionsGlobal=${promotion.maxRedemptionsGlobal} cachedOrderCount=${promotion.orderCount} ` +
`discountGroupCount=${promotion.discountGroupCount} offendingCustomerIds=${JSON.stringify(verdict.offendingCustomerIds)}`
);
console.warn(`Offending order id(s) for review: ${JSON.stringify(offendingOrderIds)}`);
if (verdict.status === "over_global" || verdict.status === "over_per_customer") {
console.warn(`${DRY_RUN ? "Would deactivate" : "Deactivating"} promotion ${promotion.id} to stop further over-redemption`);
if (!DRY_RUN) await deactivatePromotion(token, promotion.id);
flaggedOver++;
}
}
console.log(
`Done. ${promotions.length} promotion(s) checked, ${flaggedOver} over-redeemed promotion(s) ${DRY_RUN ? "to deactivate" : "deactivated"}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The evaluation function is the part most worth testing, because it decides which promotions get reported as over-redeemed. Because evaluate_promotion_usage is pure, the test needs no network and no live Shopware store, just plain fixture arrays of order and customer ids.
from evaluate_promotion_usage import evaluate_promotion_usage
def promotion(**over):
base = {
"id": "promo-1",
"maxRedemptionsGlobal": 100,
"maxRedemptionsPerCustomer": None,
"discountGroupCount": 1,
}
base.update(over)
return base
def redemption(order_id, customer_id):
return {"orderId": order_id, "customerId": customer_id}
def test_ok_when_under_the_global_limit():
redemptions = [redemption("order-1", "cust-a"), redemption("order-2", "cust-b")]
verdict = evaluate_promotion_usage(promotion(maxRedemptionsGlobal=5), redemptions)
assert verdict["status"] == "ok"
assert verdict["actualGlobalCount"] == 2
assert verdict["offendingCustomerIds"] == []
def test_over_global_when_actual_count_exceeds_max():
redemptions = [redemption(f"order-{i}", f"cust-{i}") for i in range(3)]
verdict = evaluate_promotion_usage(promotion(maxRedemptionsGlobal=2), redemptions)
assert verdict["status"] == "over_global"
assert verdict["actualGlobalCount"] == 3
def test_dedupes_by_order_id_matching_count_distinct_order_id():
# Two line items on the same order (e.g. two discount groups both fired)
# must count as one redemption, mirroring PromotionRedemptionUpdater's
# COUNT(DISTINCT order_line_item.order_id).
redemptions = [redemption("order-1", "cust-a"), redemption("order-1", "cust-a")]
verdict = evaluate_promotion_usage(promotion(maxRedemptionsGlobal=1), redemptions)
assert verdict["actualGlobalCount"] == 1
assert verdict["status"] == "ok"
def test_over_per_customer_when_one_customer_exceeds_their_max():
redemptions = [
redemption("order-1", "cust-a"),
redemption("order-2", "cust-a"),
redemption("order-3", "cust-b"),
]
verdict = evaluate_promotion_usage(
promotion(maxRedemptionsGlobal=None, maxRedemptionsPerCustomer=1), redemptions
)
assert verdict["status"] == "over_per_customer"
assert verdict["offendingCustomerIds"] == ["cust-a"]
assert verdict["perCustomerCounts"] == {"cust-a": 2, "cust-b": 1}
def test_multi_group_risk_when_counts_are_fine_but_config_is_defective():
redemptions = [redemption("order-1", "cust-a")]
verdict = evaluate_promotion_usage(
promotion(maxRedemptionsGlobal=10, discountGroupCount=2), redemptions
)
assert verdict["status"] == "multi_group_risk"
def test_no_multi_group_risk_when_only_one_discount_group():
redemptions = [redemption("order-1", "cust-a")]
verdict = evaluate_promotion_usage(
promotion(maxRedemptionsGlobal=10, discountGroupCount=1), redemptions
)
assert verdict["status"] == "ok"
def test_global_breach_takes_priority_over_multi_group_risk():
redemptions = [redemption(f"order-{i}", f"cust-{i}") for i in range(5)]
verdict = evaluate_promotion_usage(
promotion(maxRedemptionsGlobal=2, discountGroupCount=3), redemptions
)
assert verdict["status"] == "over_global"
def test_none_max_redemptions_global_never_flags_over_global():
redemptions = [redemption(f"order-{i}", f"cust-{i}") for i in range(50)]
verdict = evaluate_promotion_usage(
promotion(maxRedemptionsGlobal=None, discountGroupCount=1), redemptions
)
assert verdict["status"] == "ok"
def test_empty_redemptions_is_ok():
verdict = evaluate_promotion_usage(promotion(), [])
assert verdict["status"] == "ok"
assert verdict["actualGlobalCount"] == 0
assert verdict["perCustomerCounts"] == {}
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluatePromotionUsage } from "./evaluate-promotion-usage.js";
const promotion = (over = {}) => ({
id: "promo-1",
maxRedemptionsGlobal: 100,
maxRedemptionsPerCustomer: null,
discountGroupCount: 1,
...over,
});
const redemption = (orderId, customerId) => ({ orderId, customerId });
test("ok when under the global limit", () => {
const redemptions = [redemption("order-1", "cust-a"), redemption("order-2", "cust-b")];
const verdict = evaluatePromotionUsage(promotion({ maxRedemptionsGlobal: 5 }), redemptions);
assert.equal(verdict.status, "ok");
assert.equal(verdict.actualGlobalCount, 2);
assert.deepEqual(verdict.offendingCustomerIds, []);
});
test("over_global when actual count exceeds max", () => {
const redemptions = [0, 1, 2].map((i) => redemption(`order-${i}`, `cust-${i}`));
const verdict = evaluatePromotionUsage(promotion({ maxRedemptionsGlobal: 2 }), redemptions);
assert.equal(verdict.status, "over_global");
assert.equal(verdict.actualGlobalCount, 3);
});
test("dedupes by order id matching COUNT DISTINCT order_id", () => {
// Two line items on the same order (e.g. two discount groups both fired)
// must count as one redemption, mirroring PromotionRedemptionUpdater's
// COUNT(DISTINCT order_line_item.order_id).
const redemptions = [redemption("order-1", "cust-a"), redemption("order-1", "cust-a")];
const verdict = evaluatePromotionUsage(promotion({ maxRedemptionsGlobal: 1 }), redemptions);
assert.equal(verdict.actualGlobalCount, 1);
assert.equal(verdict.status, "ok");
});
test("over_per_customer when one customer exceeds their max", () => {
const redemptions = [
redemption("order-1", "cust-a"),
redemption("order-2", "cust-a"),
redemption("order-3", "cust-b"),
];
const verdict = evaluatePromotionUsage(
promotion({ maxRedemptionsGlobal: null, maxRedemptionsPerCustomer: 1 }),
redemptions
);
assert.equal(verdict.status, "over_per_customer");
assert.deepEqual(verdict.offendingCustomerIds, ["cust-a"]);
assert.deepEqual(verdict.perCustomerCounts, { "cust-a": 2, "cust-b": 1 });
});
test("multi_group_risk when counts are fine but config is defective", () => {
const redemptions = [redemption("order-1", "cust-a")];
const verdict = evaluatePromotionUsage(
promotion({ maxRedemptionsGlobal: 10, discountGroupCount: 2 }),
redemptions
);
assert.equal(verdict.status, "multi_group_risk");
});
test("no multi_group_risk when only one discount group", () => {
const redemptions = [redemption("order-1", "cust-a")];
const verdict = evaluatePromotionUsage(
promotion({ maxRedemptionsGlobal: 10, discountGroupCount: 1 }),
redemptions
);
assert.equal(verdict.status, "ok");
});
test("global breach takes priority over multi_group_risk", () => {
const redemptions = [0, 1, 2, 3, 4].map((i) => redemption(`order-${i}`, `cust-${i}`));
const verdict = evaluatePromotionUsage(
promotion({ maxRedemptionsGlobal: 2, discountGroupCount: 3 }),
redemptions
);
assert.equal(verdict.status, "over_global");
});
test("null maxRedemptionsGlobal never flags over_global", () => {
const redemptions = Array.from({ length: 50 }, (_, i) => redemption(`order-${i}`, `cust-${i}`));
const verdict = evaluatePromotionUsage(
promotion({ maxRedemptionsGlobal: null, discountGroupCount: 1 }),
redemptions
);
assert.equal(verdict.status, "ok");
});
test("empty redemptions is ok", () => {
const verdict = evaluatePromotionUsage(promotion(), []);
assert.equal(verdict.status, "ok");
assert.equal(verdict.actualGlobalCount, 0);
assert.deepEqual(verdict.perCustomerCounts, {});
});
Case studies
The flash sale that capped at two hundred but shipped at two hundred and forty
A furniture store ran a flash promotion capped at exactly two hundred redemptions store-wide, configured with a percentage discount group for the cart total and a second, separate free-gift discount group triggered by the same code. Both groups qualified independently on many carts during the sale's peak hour, and the free-gift group kept applying past the two hundred mark without Shopware ever rejecting the code.
Running the detection script after the sale showed actualGlobalCount at two hundred and forty-one against a maxRedemptionsGlobal of two hundred, with the cached orderCount still reading well under the cap. The team confirmed the forty-one extra orders by hand, decided to honor them as a goodwill gesture rather than cancel real customer orders, and split the promotion's two discount groups into separate single-group promotions with their own limits going forward.
The loyalty code meant for one use per customer
A subscription box brand issued a single-use welcome code, maxRedemptionsPerCustomer set to one, to new signups. A handful of customers opened the checkout in two browser tabs during a slow page load and completed both orders within seconds of each other, both reading the same not-yet-updated per-customer count and both passing validation.
The script's per-customer grouping surfaced exactly which customer ids had two orders against a one-per-customer code, with the offending order ids listed for support to review individually. Since the orders were real and already fulfilled, the brand chose to let both stand for the flagged customers and added a short delay before enabling the code on new signups going forward, rather than write anything back to the orders themselves.
After this runs, a promotion that quietly exceeded its cap becomes a clear report: the true redemption count, the configured maximum, and the exact order ids behind the gap, ready for a human to decide what to do about the orders that already went through. Nothing is written to an order or the cached counter, so no invoice is silently altered. The only automated change is switching the promotion off so the same defect cannot keep compounding while people figure out the right response.
FAQ
Why does a Shopware promotion redeem more times than its maxRedemptionsGlobal?
Shopware checks a promotion's usage limit against promotion.orderCount, a cached counter that PromotionRedemptionUpdater recomputes with COUNT(DISTINCT order_line_item.order_id) after an order is placed, not a real-time transactional check during cart validation. When a promotion has more than one discount or set group, each group can independently satisfy its own rule and add a promotion line item in the same checkout, and known defects in that discount-group evaluation path can bypass or miscount the usage check, so the cached counter falls behind the real redemption count and the promotion keeps accepting orders past its configured maximum.
Is it safe to automatically fix an over-redeemed promotion in Shopware?
No, not by touching the orders or the counter. The over-redeemed orders and invoices already exist, so retroactively cancelling them or clawing back a discount is a business decision, not something a script should decide. The safe automated action is to deactivate the promotion once its actual redemption count is confirmed to exceed the configured maximum, stopping further over-redemption, and to report the offending order ids for a human to review. Writing directly to promotion.orderCount is also unsafe, since Shopware's own indexer will just overwrite it on the next pass.
What is promotion.orderCount and why can it drift from the real redemption count?
promotion.orderCount is a cached field on the promotion entity that Shopware's PromotionRedemptionUpdater recalculates by counting distinct orders that carry a promotion line item for that promotion id. It only updates asynchronously, during DAL indexing after an order is written, so it can lag behind reality. Concurrent checkouts using the same code can both pass validation before the counter catches up, and multi-discount-group promotions can add more than one promotion line item per checkout, both of which push the real redemption count past what orderCount reports at validation time.
Related field notes
Citations
On the problem:
- Promotion, Discount, Maximum amount of usages not working, Issue #5417, shopware/shopware. github.com/shopware/shopware/issues/5417
- Issue with multiple discounts per promotion, Issue #13644, shopware/shopware. github.com/shopware/shopware/issues/13644
- Wrong set group scope discount calculation, Issue #3885, shopware/shopware. github.com/shopware/shopware/issues/3885
On the solution:
- Promotion, Admin API Reference, Shopware Stoplight. shopware.stoplight.io/docs/admin-api/d61c383ec3aa2-promotion
- Marketing, Promotions, Shopware 6 Documentation. docs.shopware.com/en/shopware-6-en/marketing/promotions
- PromotionRedemptionUpdater.php, shopware/shopware. github.com/shopware/shopware PromotionRedemptionUpdater.php
Stuck on a tricky one?
If you have a problem in Shopware orders, customers, promotions, or data hygiene 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 promotion running past its limit?
If this saved you from an over-redeemed promotion or a stack of unexplained discounts, 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