Reconciler Customers and Promotions
Lowercase promotion code entry is not marked as redeemed
A customer types summer2026 at checkout, the discount applies, and the order completes without a hitch. The code was actually stored as SUMMER2026, and it was supposed to be a one-time code. Nobody sees a problem until you notice the same code applied on a dozen orders, each with slightly different capitalization. Shopware happily accepted every one of them, and never flagged a single one as used. Here is why the checkout and the bookkeeping disagree, and a small script that finds every order this happened to.
MySQL's default _ci (case-insensitive) collation on the code columns means the checkout lookup for a promotion code matches promotion.code or promotion_individual_code.code regardless of letter case, so typing summer2026 successfully redeems a code stored as SUMMER2026 and the order completes with the discount applied. Separately, the bookkeeping that is supposed to flag a one-time individual code as used compares the exact code string captured on the completed order's line-item payload against the stored canonical code, and that comparison is case-sensitive, so it never matches and the code is never marked redeemed. A single-use code can be applied to unlimited orders as long as each customer enters a different casing. This is tracked upstream as shopware/shopware#14397 and fixed in Shopware 6.6.10.16 by normalizing the comparison. Until you are on that version, run a small Python or Node.js script that pulls completed orders, groups their promotion code usage by an uppercased, trimmed canonical form, and reports every code that was applied more than once. Full code, tests, and sources are below.
The problem in plain words
A promotion code in Shopware lives in two places that matter here: the code column on promotion for a shared code, or on promotion_individual_code for a one-time code generated per customer. When a shopper types a code at checkout, Shopware looks it up against that column to decide whether the discount applies.
That lookup runs through MySQL, and MySQL's default collation on text columns is case-insensitive. So a query for summer2026 and a stored value of SUMMER2026 are, as far as the database is concerned, the same string. The discount applies, the order completes, and everything looks correct on the surface. The trouble starts afterward, when Shopware tries to record that the one-time code has now been used. That step reads the exact code string that was captured on the completed order's line item and compares it, character for character, against the canonical stored code. Lowercase against uppercase fails that comparison every time, so the redemption flag is never set. The code looks untouched, so the next customer can type it again, in yet another casing, and the whole cycle repeats.
Why it happens
Two independent layers of Shopware disagree about whether letter case matters, and that disagreement is the entire bug:
- The checkout code lookup runs as a SQL query against
promotion.codeorpromotion_individual_code.code, and MySQL's default table collation for those text columns is case-insensitive (_ci), sosummer2026,Summer2026, andSUMMER2026are all treated as the same value by the database engine. - The redemption bookkeeping, which is what should flip a one-time code from unused to used, works from the exact code string that was written onto the order's promotion line-item payload at the time of purchase, and compares it character for character against the canonical stored code, with no normalization of case.
- Because the two layers use different comparison rules, a single stored code can satisfy checkout on every casing variant a customer might type, while never satisfying the stricter comparison that marks it redeemed.
- This is tracked upstream as
shopware/shopware#14397, and a closely related report,shopware/shopware#13552, describes individual codes being issued or accepted more times than their configured limit allows for the same underlying reason.
The practical effect is that a promotion meant to be single-use, perhaps a signup incentive or a one-per-customer discount, becomes reusable indefinitely as long as each shopper happens to type a different casing, whether by accident, autocomplete, or a copy-paste from an email that rendered it in lowercase. See the citations at the end for the exact issue and the fixed release.
You cannot see this bug by looking at one order. Every individual order looks completely normal: a promotion line item, a discount, a completed state. The only way to see the pattern is to compare code usage across every completed order at once, normalized the same way the checkout's case-insensitive lookup would treat it, and check whether more than one order used what should have been a single-use code.
The fix, as a flow
We do not try to retroactively correct anything on the order or the promotion record, because the Admin API gives us no safe way to do that. Instead the script pulls every completed order's promotion line items, reads the exact code text each one carried, groups those usages by an uppercased and trimmed canonical form, and reports any group where the same one-time code was used on more than one order. A human then decides what to do, and the only allowed write is an explicit, DRY_RUN-guarded deactivation of the promotion itself, never a synthetic redeemed flag.
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 orders, individual codes, promotions, and for the optional deactivation PATCH.
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) : {};
}
Pull completed orders and their promotion line items
Search orders whose state machine is completed, with the lineItems association loaded. For every line item where type is promotion, read payload.code, which is the exact string the customer typed at the time of purchase.
def completed_orders_with_promotions(token):
body = {
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "completed"}],
"associations": {"lineItems": {}},
"page": 1,
"limit": 500,
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
orders = r.json()["data"]
usages = [] # list of {"orderId", "orderNumber", "code"}
for order in orders:
line_items = ((order.get("lineItems") or {}).get("data")) or order.get("lineItems") or []
for item in line_items:
if item.get("type") != "promotion":
continue
code = (item.get("payload") or {}).get("code")
if not code:
continue
usages.append({"orderId": order["id"], "orderNumber": order.get("orderNumber"), "code": code})
return usages
async function completedOrdersWithPromotions(token) {
const body = {
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "completed" }],
associations: { lineItems: {} },
page: 1,
limit: 500,
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
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 search`);
const data = await res.json();
const orders = data.data;
const usages = [];
for (const order of orders) {
const lineItems = order.lineItems?.data || order.lineItems || [];
for (const item of lineItems) {
if (item.type !== "promotion") continue;
const code = item.payload?.code;
if (!code) continue;
usages.push({ orderId: order.id, orderNumber: order.orderNumber, code });
}
}
return usages;
}
Decide, with one pure function
Keep the reuse decision in its own function that takes only a stored canonical code and the list of order usages already extracted from line-item payloads. It normalizes with trim().toUpperCase(), the same way the case-insensitive checkout lookup would treat the code, and returns whether the code was used on more than one order along with which usages differed only in casing from the canonical value. Nothing here touches the network, so it is simple to test with plain fixtures.
def detect_case_mismatch_reuse(stored_code, order_code_usages):
canonical = stored_code.strip().upper()
matching_usages = [u for u in order_code_usages if u["code"].strip().upper() == canonical]
reused = len(matching_usages) > 1
mismatches = [
{"orderId": u["orderId"], "appliedCode": u["code"]}
for u in matching_usages
if u["code"].strip() != stored_code.strip()
]
return {"reused": reused, "mismatches": mismatches}
export function detectCaseMismatchReuse(storedCode, orderCodeUsages) {
const canonical = storedCode.trim().toUpperCase();
const matchingUsages = orderCodeUsages.filter((u) => u.code.trim().toUpperCase() === canonical);
const reused = matchingUsages.length > 1;
const mismatches = matchingUsages
.filter((u) => u.code.trim() !== storedCode.trim())
.map((u) => ({ orderId: u.orderId, appliedCode: u.code }));
return { reused, mismatches };
}
Fetch the canonical stored codes and group usages
Collect the distinct codes seen on completed orders, then look them up against promotion-individual-code for one-time codes and promotion for global or fixed codes with equalsAny. For each canonical code, run the pure function against every usage of that code across all completed orders.
def fetch_canonical_codes(token, codes_seen):
canonical_codes = set()
individual = api_post("/api/search/promotion-individual-code", token, {
"filter": [{"type": "equalsAny", "field": "code", "value": list(codes_seen)}],
"page": 1, "limit": 500,
})
for row in individual.get("data", []):
canonical_codes.add(row["code"])
fixed = api_post("/api/search/promotion", token, {
"filter": [{"type": "equalsAny", "field": "code", "value": list(codes_seen)}],
"page": 1, "limit": 500,
})
for row in fixed.get("data", []):
if row.get("code"):
canonical_codes.add(row["code"])
return canonical_codes
async function fetchCanonicalCodes(token, codesSeen) {
const canonicalCodes = new Set();
const individual = await apiPost("/api/search/promotion-individual-code", token, {
filter: [{ type: "equalsAny", field: "code", value: [...codesSeen] }],
page: 1, limit: 500,
});
for (const row of individual.data || []) canonicalCodes.add(row.code);
const fixed = await apiPost("/api/search/promotion", token, {
filter: [{ type: "equalsAny", field: "code", value: [...codesSeen] }],
page: 1, limit: 500,
});
for (const row of fixed.data || []) {
if (row.code) canonicalCodes.add(row.code);
}
return canonicalCodes;
}
Wire it together with a dry run guard
The loop ties every piece together. It reports every canonical code that shows reuse, listing each affected order and the exact casing that was applied. The only write the script will ever attempt is deactivating a still-abusable promotion, and only when DRY_RUN is off and only after the affected orders have been printed for a human to review. It never touches promotion-individual-code directly, because there is no safe, PATCH-able redeemed flag to correct.
This script cannot retroactively mark past orders as using a redeemed code, and it should not try to. Start with DRY_RUN=true, read the list of affected orders and codes, and only if you explicitly want to stop further abuse should you set DRY_RUN=false, which deactivates the promotion via PATCH /api/promotion/{promotionId} with {"active": false}. The durable fix is upgrading to Shopware 6.6.10.16 or later.
The full code
Here is the complete script in one file for each language. It authenticates, pulls completed orders with their promotion line items, fetches the canonical stored codes, groups usages with the pure function, logs every reused code with its affected orders and casings, and only deactivates a promotion when explicitly told to and not in dry run.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Detect Shopware 6 promotion codes that were redeemed multiple times because
checkout's case-insensitive lookup accepted every casing while the redemption
bookkeeping compares case-sensitively and never flags the code as used.
MySQL's default _ci collation on promotion.code and promotion_individual_code.code
means a lookup for "summer2026" matches a stored "SUMMER2026" at checkout, so the
discount applies and the order completes. But the step that marks a one-time code
as redeemed compares the exact code string captured on the order's line item
against the canonical stored code, character for character, so lowercase against
uppercase never matches and the code is never flagged used. A single-use code can
then be applied to unlimited orders as long as each customer types a different
casing. Tracked upstream as shopware/shopware#14397, fixed in Shopware 6.6.10.16.
This is flag-and-report only. There is no PATCH-able redeemed boolean on
promotion-individual-code to retroactively correct, and synthesizing one risks
corrupting redemption analytics or unfairly blocking a legitimate order. The only
write this script will ever perform is an explicit, DRY_RUN-guarded deactivation
of the still-abusable promotion, after listing every affected order for review.
Guide: https://www.allanninal.dev/shopware/promotion-code-lowercase-not-redeemed/
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("detect_case_mismatch_reuse")
SHOPWARE_URL = os.environ.get("SHOPWARE_URL", "https://example.test").rstrip("/")
CLIENT_ID = os.environ.get("SHOPWARE_CLIENT_ID", "dummy-client-id")
CLIENT_SECRET = os.environ.get("SHOPWARE_CLIENT_SECRET", "dummy-secret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def detect_case_mismatch_reuse(stored_code, order_code_usages):
"""Pure decision. No I/O. Takes the stored canonical code and a list of
{orderId, code} pairs already extracted from order line-item payloads."""
canonical = stored_code.strip().upper()
matching_usages = [u for u in order_code_usages if u["code"].strip().upper() == canonical]
reused = len(matching_usages) > 1
mismatches = [
{"orderId": u["orderId"], "appliedCode": u["code"]}
for u in matching_usages
if u["code"].strip() != stored_code.strip()
]
return {"reused": reused, "mismatches": mismatches}
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 {}
def completed_orders_with_promotions(token):
body = {
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "completed"}],
"associations": {"lineItems": {}},
"page": 1,
"limit": 500,
"total-count-mode": 1,
}
data = api_post("/api/search/order", token, body)
orders = data.get("data", [])
usages = []
for order in orders:
line_items = ((order.get("lineItems") or {}).get("data")) or order.get("lineItems") or []
for item in line_items:
if item.get("type") != "promotion":
continue
code = (item.get("payload") or {}).get("code")
if not code:
continue
usages.append({"orderId": order["id"], "orderNumber": order.get("orderNumber"), "code": code})
return usages
def fetch_canonical_codes(token, codes_seen):
canonical_codes = {} # canonical upper -> {"code": original, "entity": "...", "id": "..."}
individual = api_post("/api/search/promotion-individual-code", token, {
"filter": [{"type": "equalsAny", "field": "code", "value": list(codes_seen)}],
"page": 1, "limit": 500,
})
for row in individual.get("data", []):
canonical_codes[row["code"].strip().upper()] = {"code": row["code"], "entity": "promotion-individual-code", "id": row["id"], "promotionId": row.get("promotionId")}
fixed = api_post("/api/search/promotion", token, {
"filter": [{"type": "equalsAny", "field": "code", "value": list(codes_seen)}],
"page": 1, "limit": 500,
})
for row in fixed.get("data", []):
if row.get("code"):
canonical_codes.setdefault(row["code"].strip().upper(), {"code": row["code"], "entity": "promotion", "id": row["id"], "promotionId": row["id"]})
return canonical_codes
def deactivate_promotion(token, promotion_id):
return 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,
)
def run():
token = get_token()
usages = completed_orders_with_promotions(token)
codes_seen = {u["code"].strip().upper() for u in usages}
if not codes_seen:
log.info("Done. No completed orders with promotion line items found.")
return
canonical_codes = fetch_canonical_codes(token, [u["code"] for u in usages])
reused_count = 0
for canonical_upper, meta in canonical_codes.items():
usages_for_code = [u for u in usages if u["code"].strip().upper() == canonical_upper]
result = detect_case_mismatch_reuse(meta["code"], usages_for_code)
if not result["reused"]:
continue
reused_count += 1
order_ids = sorted({u["orderId"] for u in usages_for_code})
log.warning(
"Code '%s' (%s) reused across %d order(s): %s",
meta["code"], meta["entity"], len(order_ids), order_ids,
)
for mismatch in result["mismatches"]:
log.warning(" order %s applied casing '%s'", mismatch["orderId"], mismatch["appliedCode"])
if meta.get("promotionId"):
log.warning(
"Promotion %s is still abusable. %s",
meta["promotionId"], "would deactivate" if DRY_RUN else "deactivating",
)
if not DRY_RUN:
deactivate_promotion(token, meta["promotionId"])
log.info("Done. %d code(s) show case-mismatch reuse out of %d canonical code(s) checked.", reused_count, len(canonical_codes))
if __name__ == "__main__":
run()
/**
* Detect Shopware 6 promotion codes that were redeemed multiple times because
* checkout's case-insensitive lookup accepted every casing while the redemption
* bookkeeping compares case-sensitively and never flags the code as used.
*
* MySQL's default _ci collation on promotion.code and promotion_individual_code.code
* means a lookup for "summer2026" matches a stored "SUMMER2026" at checkout, so the
* discount applies and the order completes. But the step that marks a one-time code
* as redeemed compares the exact code string captured on the order's line item
* against the canonical stored code, character for character, so lowercase against
* uppercase never matches and the code is never flagged used. A single-use code can
* then be applied to unlimited orders as long as each customer types a different
* casing. Tracked upstream as shopware/shopware#14397, fixed in Shopware 6.6.10.16.
*
* This is flag-and-report only. There is no PATCH-able redeemed boolean on
* promotion-individual-code to retroactively correct, and synthesizing one risks
* corrupting redemption analytics or unfairly blocking a legitimate order. The only
* write this script will ever perform is an explicit, DRY_RUN-guarded deactivation
* of the still-abusable promotion, after listing every affected order for review.
*
* Guide: https://www.allanninal.dev/shopware/promotion-code-lowercase-not-redeemed/
* Run on demand. Safe to run again and again.
*/
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";
export function detectCaseMismatchReuse(storedCode, orderCodeUsages) {
const canonical = storedCode.trim().toUpperCase();
const matchingUsages = orderCodeUsages.filter((u) => u.code.trim().toUpperCase() === canonical);
const reused = matchingUsages.length > 1;
const mismatches = matchingUsages
.filter((u) => u.code.trim() !== storedCode.trim())
.map((u) => ({ orderId: u.orderId, appliedCode: u.code }));
return { reused, mismatches };
}
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) : {};
}
async function completedOrdersWithPromotions(token) {
const body = {
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "completed" }],
associations: { lineItems: {} },
page: 1,
limit: 500,
"total-count-mode": 1,
};
const data = await apiPost("/api/search/order", token, body);
const orders = data.data || [];
const usages = [];
for (const order of orders) {
const lineItems = order.lineItems?.data || order.lineItems || [];
for (const item of lineItems) {
if (item.type !== "promotion") continue;
const code = item.payload?.code;
if (!code) continue;
usages.push({ orderId: order.id, orderNumber: order.orderNumber, code });
}
}
return usages;
}
async function fetchCanonicalCodes(token, codesSeen) {
const canonicalCodes = new Map(); // canonical upper -> {code, entity, id, promotionId}
const individual = await apiPost("/api/search/promotion-individual-code", token, {
filter: [{ type: "equalsAny", field: "code", value: [...codesSeen] }],
page: 1, limit: 500,
});
for (const row of individual.data || []) {
canonicalCodes.set(row.code.trim().toUpperCase(), { code: row.code, entity: "promotion-individual-code", id: row.id, promotionId: row.promotionId });
}
const fixed = await apiPost("/api/search/promotion", token, {
filter: [{ type: "equalsAny", field: "code", value: [...codesSeen] }],
page: 1, limit: 500,
});
for (const row of fixed.data || []) {
if (!row.code) continue;
const key = row.code.trim().toUpperCase();
if (!canonicalCodes.has(key)) {
canonicalCodes.set(key, { code: row.code, entity: "promotion", id: row.id, promotionId: row.id });
}
}
return canonicalCodes;
}
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} deactivating promotion ${promotionId}`);
}
export async function run() {
const token = await getToken();
const usages = await completedOrdersWithPromotions(token);
const codesSeen = new Set(usages.map((u) => u.code.trim().toUpperCase()));
if (codesSeen.size === 0) {
console.log("Done. No completed orders with promotion line items found.");
return;
}
const canonicalCodes = await fetchCanonicalCodes(token, usages.map((u) => u.code));
let reusedCount = 0;
for (const [canonicalUpper, meta] of canonicalCodes) {
const usagesForCode = usages.filter((u) => u.code.trim().toUpperCase() === canonicalUpper);
const result = detectCaseMismatchReuse(meta.code, usagesForCode);
if (!result.reused) continue;
reusedCount++;
const orderIds = [...new Set(usagesForCode.map((u) => u.orderId))].sort();
console.warn(`Code '${meta.code}' (${meta.entity}) reused across ${orderIds.length} order(s): ${orderIds}`);
for (const mismatch of result.mismatches) {
console.warn(` order ${mismatch.orderId} applied casing '${mismatch.appliedCode}'`);
}
if (meta.promotionId) {
console.warn(`Promotion ${meta.promotionId} is still abusable. ${DRY_RUN ? "would deactivate" : "deactivating"}`);
if (!DRY_RUN) await deactivatePromotion(token, meta.promotionId);
}
}
console.log(`Done. ${reusedCount} code(s) show case-mismatch reuse out of ${canonicalCodes.size} canonical code(s) checked.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The reuse decision is the part most worth testing, because it decides which codes get reported and, later, which promotions a human might choose to deactivate. Because detect_case_mismatch_reuse is pure and takes a plain stored code and a list of usage pairs, the test needs no network and no live Shopware store.
from detect_case_mismatch_reuse import detect_case_mismatch_reuse
def usage(order_id, code):
return {"orderId": order_id, "code": code}
def test_not_reused_with_a_single_usage():
result = detect_case_mismatch_reuse("SUMMER2026", [usage("o1", "SUMMER2026")])
assert result["reused"] is False
assert result["mismatches"] == []
def test_reused_when_two_orders_use_different_casing():
usages = [usage("o1", "summer2026"), usage("o2", "SUMMER2026")]
result = detect_case_mismatch_reuse("SUMMER2026", usages)
assert result["reused"] is True
assert result["mismatches"] == [{"orderId": "o1", "appliedCode": "summer2026"}]
def test_reused_when_two_orders_use_exact_same_casing():
usages = [usage("o1", "SUMMER2026"), usage("o2", "SUMMER2026")]
result = detect_case_mismatch_reuse("SUMMER2026", usages)
assert result["reused"] is True
assert result["mismatches"] == []
def test_ignores_usages_of_other_codes():
usages = [usage("o1", "SUMMER2026"), usage("o2", "WINTER2026")]
result = detect_case_mismatch_reuse("SUMMER2026", usages)
assert result["reused"] is False
def test_trims_whitespace_before_comparing():
usages = [usage("o1", " summer2026 "), usage("o2", "SUMMER2026")]
result = detect_case_mismatch_reuse("SUMMER2026", usages)
assert result["reused"] is True
assert result["mismatches"] == [{"orderId": "o1", "appliedCode": " summer2026 "}]
def test_three_orders_all_lowercase_variants():
usages = [usage("o1", "Summer2026"), usage("o2", "SUMMER2026"), usage("o3", "summer2026")]
result = detect_case_mismatch_reuse("SUMMER2026", usages)
assert result["reused"] is True
assert len(result["mismatches"]) == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectCaseMismatchReuse } from "./detect-case-mismatch-reuse.js";
const usage = (orderId, code) => ({ orderId, code });
test("not reused with a single usage", () => {
const result = detectCaseMismatchReuse("SUMMER2026", [usage("o1", "SUMMER2026")]);
assert.equal(result.reused, false);
assert.deepEqual(result.mismatches, []);
});
test("reused when two orders use different casing", () => {
const usages = [usage("o1", "summer2026"), usage("o2", "SUMMER2026")];
const result = detectCaseMismatchReuse("SUMMER2026", usages);
assert.equal(result.reused, true);
assert.deepEqual(result.mismatches, [{ orderId: "o1", appliedCode: "summer2026" }]);
});
test("reused when two orders use exact same casing", () => {
const usages = [usage("o1", "SUMMER2026"), usage("o2", "SUMMER2026")];
const result = detectCaseMismatchReuse("SUMMER2026", usages);
assert.equal(result.reused, true);
assert.deepEqual(result.mismatches, []);
});
test("ignores usages of other codes", () => {
const usages = [usage("o1", "SUMMER2026"), usage("o2", "WINTER2026")];
const result = detectCaseMismatchReuse("SUMMER2026", usages);
assert.equal(result.reused, false);
});
test("trims whitespace before comparing", () => {
const usages = [usage("o1", " summer2026 "), usage("o2", "SUMMER2026")];
const result = detectCaseMismatchReuse("SUMMER2026", usages);
assert.equal(result.reused, true);
assert.deepEqual(result.mismatches, [{ orderId: "o1", appliedCode: " summer2026 " }]);
});
test("three orders, all lowercase variants", () => {
const usages = [usage("o1", "Summer2026"), usage("o2", "SUMMER2026"), usage("o3", "summer2026")];
const result = detectCaseMismatchReuse("SUMMER2026", usages);
assert.equal(result.reused, true);
assert.equal(result.mismatches.length, 2);
});
Case studies
The welcome code that got emailed in lowercase
A store generated one-time individual codes for a welcome offer and sent them by email, with the template rendering the code in lowercase for readability. Customers pasted exactly what they saw, checkout accepted every one, and the marketing team assumed each code was consumed after first use, as designed.
Running the detector against completed orders turned up a dozen individual codes that had each been applied on two or three separate orders, always in lowercase against a stored uppercase value. Nothing had actually broken the discount math, but the one-per-customer promise the offer was built on had quietly failed for every one of those codes. The team upgraded to Shopware 6.6.10.16 and regenerated the affected codes going forward.
The influencer code shared beyond its limit
An affiliate program issued a single shared code, meant to track referrals from one creator, with no hard usage cap enforced beyond the intent that it be lightly used. When the code leaked into a coupon aggregator site, some visitors typed it in title case or all lowercase, and the store's finance team could not reconcile why redemption counts in their own reporting looked lower than the actual discount volume in the ledger.
The script's report, grouped by canonical uppercase code, showed the real total usage across every casing variant at once, something no single order or the promotion's own counter surfaced on its own. That gave the team an accurate picture for negotiating with the affiliate and a clear, human-reviewed decision point on whether to deactivate the code immediately or let it expire on schedule.
After running this, a promotion code stops being a mystery in your revenue and redemption reports. Every canonical code has an honest usage count across every casing a customer might have typed, every case-mismatch reuse is listed with the exact orders and casings involved, and no automated write ever pretends to fix something the Admin API cannot safely fix. The only action a human takes is an informed one: deactivate a still-abusable code, or leave it and plan the upgrade to Shopware 6.6.10.16.
FAQ
Why does a lowercase promotion code still work at checkout?
MySQL's default collation on the code columns is case-insensitive, so a lookup for summer2026 matches a stored code of SUMMER2026 just fine and the discount applies. The checkout never notices the casing is different, because the database comparison treats the two strings as equal.
If the discount applies, why is the code not marked as redeemed?
The redemption flag is set by a separate, case-sensitive comparison between the exact code string captured on the completed order's line item and the canonical stored code. Since one is lowercase and the other is the original casing, that exact-match comparison fails, so a one-time individual code can be applied to unlimited orders as long as each customer types a different casing.
Can I safely auto-fix codes that were already reused this way?
No. The Admin API has no PATCH-able redeemed boolean or order link on promotion-individual-code to correct after the fact, and writing one yourself risks corrupting redemption analytics or blocking a legitimate order. Treat this as flag and report only, list every affected order for a human to review, and upgrade to Shopware 6.6.10.16 or later for the durable fix.
Related field notes
Citations
On the problem:
- Promotion individual code is not marked as redeemed when entering the code in lowercase letters (shopware/shopware #14397). github.com/shopware/shopware/issues/14397
- Individual Promotion Code can be issued multiple times (shopware/shopware #13552). github.com/shopware/shopware/issues/13552
- Shopware 6 Documentation: Marketing, Promotions. docs.shopware.com/en/shopware-6-en/marketing/promotions
On the solution:
- Shopware Release Notes 6.6.10.16, the fix that normalizes the code comparison. developer.shopware.com/release-notes/6.6/6.6.10.16.html
- Shopware Admin API: PromotionIndividualCode entity reference. shopware.stoplight.io/docs/admin-api/promotion-individual-code
- Shopware Developer Documentation: Search Criteria, Associations and Filters. developer.shopware.com/docs/guides/integrations-api/general-concepts/search-criteria.html
Stuck on a tricky one?
If you have a problem in Shopware orders, payments, promotions, or the message queue 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 leaking code?
If this saved your one-time promotion from being redeemed a dozen times over, 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