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.

Python and Node.js Admin API Safe by default (dry run)
The short answer

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.

Customer types "summer2026" _ci lookup matches stored "SUMMER2026" Discount applies order completes Redemption check exact case, never matches Code stays "unused" next customer redeems it too unlimited reuse, one code
The checkout accepts any casing thanks to MySQL's collation. The redemption flag compares casing exactly, so it never fires, and the loop repeats for the next shopper.

Why it happens

Two independent layers of Shopware disagree about whether letter case matters, and that disagreement is the entire bug:

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.

The key insight

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.

List completed orders with promotion line items Fetch canonical codes promotion-individual-code and promotion Group by canonical trim().toUpperCase() More than one order? no Fine, no report yes, reused Report orders and casings Human review, then optional DRY_RUN-guarded deactivate
The script only ever reports. Deactivating the still-abusable promotion is an explicit, human-approved, dry run guarded step, never automatic.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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 {}
step2.js
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) : {};
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

decide.py
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}
decide.js
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 };
}
5

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.

fetch_codes.py
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
fetch-codes.js
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;
}
6

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.

Run it safe

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_case_mismatch_reuse.py
"""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-case-mismatch-reuse.js
/**
 * 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.

test_promotion_reuse.py
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
promotion-reuse.test.js
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

Signup incentive

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.

Affiliate codes

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.

What good looks like

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:

  1. Promotion individual code is not marked as redeemed when entering the code in lowercase letters (shopware/shopware #14397). github.com/shopware/shopware/issues/14397
  2. Individual Promotion Code can be issued multiple times (shopware/shopware #13552). github.com/shopware/shopware/issues/13552
  3. Shopware 6 Documentation: Marketing, Promotions. docs.shopware.com/en/shopware-6-en/marketing/promotions

On the solution:

  1. 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
  2. Shopware Admin API: PromotionIndividualCode entity reference. shopware.stoplight.io/docs/admin-api/promotion-individual-code
  3. 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.

Contact me on LinkedIn

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

Back to all Shopware field notes