Reconciler Customers and Promotions
Cancelled orders do not release their reserved promotion code
A customer checks out with a one-time promotion code, then the order gets cancelled, whether by the customer, a failed payment, or a support agent cleaning up test data. The order is gone. The code is not free. It sits marked as redeemed forever, unusable by the next customer who tries it, even though nothing is actually holding it anymore. Here is why Shopware never lets go of that code and a small script that finds the ones stuck this way and reports them for review.
Shopware 6 tracks individual promotion code redemption inside the promotion-individual-code entity's payload JSON field, in a redeemed list keyed by order and order customer, written by the PromotionRedeemUpdater subscriber when the order is placed. No subscriber is wired to the order's cancel state-machine transition to remove that entry, so the code stays marked used even after the only order that redeemed it is cancelled. This is a long-standing, still-open core gap, tracked upstream as shopware/shopware#4382 and #5830. Run a small Python or Node.js script that finds cancelled orders using promotion codes, reads each code's payload.redeemed ledger, and flags a code only when every order in that ledger is cancelled. There is no documented release-code endpoint, so the script reports by default and only writes a trimmed payload behind an explicit opt-in flag, after a dry run prints the exact before and after JSON. Full code, tests, and sources are below.
The problem in plain words
A promotion in Shopware 6 can hand out individual, one-time-use codes instead of one shared code for everyone. Each of those lives as its own promotion-individual-code row. When a customer places an order with one of those codes, PromotionRedeemUpdater writes an entry into that row's payload field, a JSON blob with a redeemed array holding the order id, the order customer id, and when it happened. That is the entire record of "this code has been used." There is no simple boolean, no separate state machine, just that list.
The trouble is what happens next has no counterpart going the other way. Cancel the order, through the Administration or through POST /api/_action/order/{id}/state/cancel, and the order's own state moves to cancelled cleanly. But nothing in that transition reaches back into the promotion code's payload and removes the entry it wrote at placement. The release logic that does exist in Shopware is narrower than that, tied to conditions like removing the promotion line item before the order completes, not to a plain order cancel. So the code keeps looking used to every future checkout, permanently, even though the only order that ever consumed it no longer exists.
Why it happens
This is not a state machine bug on the order. It is a missing subscriber on the promotion side. A few things make it easy to hit and hard to notice:
- Redemption is not a status flag. It is a
redeemedarray insidepromotion-individual-code.payload, an internal JSON structure thatPromotionRedeemUpdaterowns, so there is no obvious field to inspect in a quick Admin API search. - Order cancellation and promotion code redemption were built as two separate concerns. The order's own state machine transition to
cancelledhas nothing wired to it that reaches into a different entity's payload. - The release logic that does exist targets a narrower case, removing the promotion line item from an order before it completes, not a straightforward cancel of a completed or open order.
- This exact gap has been reported to Shopware twice, in issue #4382 and issue #5830, and both were closed without a fix landing, so it is a known, still-open limitation rather than a one-off regression.
- The parent promotion's own aggregate usage counters read from the same orphaned entries, so a promotion can look like it is running low on uses when a chunk of its "usage" is actually dead orders.
Redeeming a code and cancelling an order are two different write paths that Shopware never reconciled. That means the fix is not "release every code from a cancelled order automatically." A code is only truly stuck if every single order that ever redeemed it is cancelled. If even one live order still legitimately holds that redemption, releasing the code would let it be reused while it is still attached to something real. So the safe rule is strict: flag a code only when its entire redeemed list is orphaned orders, and leave everything else alone for a human to review in Marketing, Promotions.
The fix, as a flow
We do not call any endpoint that does not exist. The script searches cancelled orders for promotion line items, collects the distinct codes involved, reads each code's payload.redeemed ledger directly, and cross-references every order id in that ledger against its real current state. Only when every referenced order is cancelled does a code get flagged as releasable, and only with an explicit opt-in does the script attempt a guarded, current-minus-orphaned PATCH.
Build it step by step
Get Admin API credentials
Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" # start safe, change to false to apply
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" // start safe, change to false to apply
Authenticate and talk to the Admin API
A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for the guarded, opt-in 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,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Find cancelled orders that used a promotion code
Search order for the cancelled state, with the lineItems and transactions associations. Keep only line items whose type is promotion, and read the code from lineItem.payload.code. Page with page and limit, and read total-count-mode:1 so you know when to stop.
def search_cancelled_orders_with_promotions(token, page=1, limit=200):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals",
"field": "stateMachineState.technicalName",
"value": "cancelled"}],
"associations": {"lineItems": {}, "transactions": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def codes_used_by_order(order):
line_items = order.get("lineItems") or []
codes = set()
for item in line_items:
if item.get("type") != "promotion":
continue
code = (item.get("payload") or {}).get("code")
if code:
codes.add(code)
return codes
async function searchCancelledOrdersWithPromotions(token, page = 1, limit = 200) {
const body = {
page,
limit,
filter: [{ type: "equals",
field: "stateMachineState.technicalName",
value: "cancelled" }],
associations: { lineItems: {}, transactions: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
function codesUsedByOrder(order) {
const lineItems = order.lineItems || [];
const codes = new Set();
for (const item of lineItems) {
if (item.type !== "promotion") continue;
const code = item.payload?.code;
if (code) codes.add(code);
}
return codes;
}
Read the code's real redemption ledger
For each distinct code, search promotion-individual-code by code equals, and read the payload field. The redeemed array inside it, not any status flag, is the actual list of orders that consumed this code. Then look up every referenced orderId in that list to get its real, current stateMachineState.technicalName.
def find_individual_code(token, code):
body = {"filter": [{"type": "equals", "field": "code", "value": code}]}
data = api("POST", "/api/search/promotion-individual-code", token, body)
rows = data.get("data", [])
return rows[0] if rows else None
def fetch_order_states(token, order_ids):
if not order_ids:
return {}
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(order_ids)}],
"associations": {"stateMachineState": {}},
}
data = api("POST", "/api/search/order", token, body)
return {
row["id"]: {
"id": row["id"],
"stateMachineTechnicalName": (row.get("stateMachineState") or {}).get("technicalName"),
}
for row in data.get("data", [])
}
async function findIndividualCode(token, code) {
const body = { filter: [{ type: "equals", field: "code", value: code }] };
const data = await api("POST", "/api/search/promotion-individual-code", token, body);
const rows = data.data || [];
return rows.length ? rows[0] : null;
}
async function fetchOrderStates(token, orderIds) {
if (orderIds.length === 0) return new Map();
const body = {
filter: [{ type: "equalsAny", field: "id", value: orderIds }],
associations: { stateMachineState: {} },
};
const data = await api("POST", "/api/search/order", token, body);
const map = new Map();
for (const row of data.data || []) {
map.set(row.id, {
id: row.id,
stateMachineTechnicalName: row.stateMachineState?.technicalName,
});
}
return map;
}
Decide, with one pure function
This is the rule that matters most. Given the code's payload.redeemed list and a lookup of order id to real state, a code is releasable only when every redeemed entry points at an order that exists and is cancelled. An entry whose order cannot be found is not treated as orphaned, that is a data-integrity gap, not a cancellation, and it must be reported separately rather than folded into a release. If even one entry belongs to a still-active order, the whole code stays put.
def decide_orphaned_redeemed_codes(codes, orders):
"""codes: list of {code, promotionId, payload: {redeemed: [{orderId, orderCustomerId, redeemedAt}]}}
orders: dict of orderId -> {id, stateMachineTechnicalName}
Returns list of {code, promotionId, orphanedOrderIds}."""
releasable = []
for entry in codes:
redeemed = ((entry.get("payload") or {}).get("redeemed")) or []
if not redeemed:
continue
orphaned_ids = []
for row in redeemed:
order_id = row.get("orderId")
order = orders.get(order_id)
if order is not None and order.get("stateMachineTechnicalName") == "cancelled":
orphaned_ids.append(order_id)
if len(orphaned_ids) == len(redeemed):
releasable.append({
"code": entry["code"],
"promotionId": entry.get("promotionId"),
"orphanedOrderIds": orphaned_ids,
})
return releasable
/**
* codes: Array<{ code, promotionId, payload: { redeemed: Array<{ orderId, orderCustomerId, redeemedAt }> } }>
* orders: Map<orderId, { id, stateMachineTechnicalName }>
* Returns Array<{ code, promotionId, orphanedOrderIds }>
*/
export function decideOrphanedRedeemedCodes(codes, orders) {
const releasable = [];
for (const entry of codes) {
const redeemed = entry.payload?.redeemed || [];
if (redeemed.length === 0) continue;
const orphanedIds = [];
for (const row of redeemed) {
const order = orders.get(row.orderId);
if (order && order.stateMachineTechnicalName === "cancelled") {
orphanedIds.push(row.orderId);
}
}
if (orphanedIds.length === redeemed.length) {
releasable.push({
code: entry.code,
promotionId: entry.promotionId,
orphanedOrderIds: orphanedIds,
});
}
}
return releasable;
}
Report by default, patch only with an explicit opt-in
There is no documented /api/_action/promotion/{id}/release-code route. The redemption ledger lives inside the internal payload blob, so every flagged code is logged as a report row by default. Only when DRY_RUN is explicitly set to false does the script re-fetch the current payload, print the exact before and after JSON, and PATCH promotion-individual-code/{id} with the orphaned entries removed and everything else kept, never a blind overwrite.
Always start with DRY_RUN=true and read the printed report before doing anything else. There is no dedicated release endpoint here, only a direct PATCH to an internal payload field, so treat every flagged code as something for a human to confirm in Marketing, Promotions, before you ever flip the flag to write.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever writes a current-minus-orphaned payload when the operator has explicitly opted in.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 promotion codes stuck redeemed by only-cancelled orders, and report them, safely.
Shopware 6 tracks individual promotion code redemption inside the promotion-individual-code
entity's payload JSON field, in a redeemed list keyed by order and orderCustomer, written by
the PromotionRedeemUpdater subscriber at order placement. No subscriber is wired to the
order's cancel state-machine transition to remove that entry, so the code stays marked
redeemed forever even after the only order that used it is cancelled. There is no documented
release-code admin API action, so this script reports every code where every redeemed order
is cancelled, and only writes a trimmed payload behind an explicit opt-in flag, after printing
the exact before and after JSON. Run on a schedule or on demand. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("release_orphaned_promotion_codes")
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"
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_cancelled_orders_with_promotions(token, page=1, limit=200):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals",
"field": "stateMachineState.technicalName",
"value": "cancelled"}],
"associations": {"lineItems": {}, "transactions": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def codes_used_by_order(order):
line_items = order.get("lineItems") or []
codes = set()
for item in line_items:
if item.get("type") != "promotion":
continue
code = (item.get("payload") or {}).get("code")
if code:
codes.add(code)
return codes
def find_individual_code(token, code):
body = {"filter": [{"type": "equals", "field": "code", "value": code}]}
data = api("POST", "/api/search/promotion-individual-code", token, body)
rows = data.get("data", [])
return rows[0] if rows else None
def fetch_order_states(token, order_ids):
if not order_ids:
return {}
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(order_ids)}],
"associations": {"stateMachineState": {}},
}
data = api("POST", "/api/search/order", token, body)
return {
row["id"]: {
"id": row["id"],
"stateMachineTechnicalName": (row.get("stateMachineState") or {}).get("technicalName"),
}
for row in data.get("data", [])
}
def decide_orphaned_redeemed_codes(codes, orders):
"""codes: list of {code, promotionId, payload: {redeemed: [{orderId, orderCustomerId, redeemedAt}]}}
orders: dict of orderId -> {id, stateMachineTechnicalName}
Returns list of {code, promotionId, orphanedOrderIds}."""
releasable = []
for entry in codes:
redeemed = ((entry.get("payload") or {}).get("redeemed")) or []
if not redeemed:
continue
orphaned_ids = []
for row in redeemed:
order_id = row.get("orderId")
order = orders.get(order_id)
if order is not None and order.get("stateMachineTechnicalName") == "cancelled":
orphaned_ids.append(order_id)
if len(orphaned_ids) == len(redeemed):
releasable.append({
"code": entry["code"],
"promotionId": entry.get("promotionId"),
"orphanedOrderIds": orphaned_ids,
})
return releasable
def release_code(token, individual_code_row, orphaned_order_ids):
"""Guarded write: current-minus-orphaned, never a blind overwrite."""
current_payload = individual_code_row.get("payload") or {}
current_redeemed = current_payload.get("redeemed") or []
kept = [row for row in current_redeemed if row.get("orderId") not in set(orphaned_order_ids)]
new_payload = dict(current_payload)
new_payload["redeemed"] = kept
log.info("Would PATCH promotion-individual-code/%s payload from %s to %s",
individual_code_row["id"], json.dumps(current_payload), json.dumps(new_payload))
if DRY_RUN:
return new_payload
api("PATCH", f"/api/promotion-individual-code/{individual_code_row['id']}", token,
{"payload": new_payload})
return new_payload
def run():
token = get_token()
codes_seen = {}
page = 1
while True:
data = search_cancelled_orders_with_promotions(token, page=page)
rows = data.get("data", [])
if not rows:
break
for order in rows:
for code in codes_used_by_order(order):
codes_seen.setdefault(code, None)
if page * 200 >= data.get("total", 0):
break
page += 1
code_entries = []
code_rows_by_code = {}
all_order_ids = set()
for code in codes_seen:
row = find_individual_code(token, code)
if row is None:
continue
code_rows_by_code[code] = row
payload = row.get("payload") or {}
redeemed = payload.get("redeemed") or []
for r in redeemed:
if r.get("orderId"):
all_order_ids.add(r["orderId"])
code_entries.append({
"code": code,
"promotionId": row.get("promotionId"),
"payload": payload,
})
orders_by_id = fetch_order_states(token, all_order_ids)
releasable = decide_orphaned_redeemed_codes(code_entries, orders_by_id)
for item in releasable:
log.warning(
"Code %s is stuck redeemed, every order (%s) is cancelled. %s",
item["code"], ", ".join(item["orphanedOrderIds"]),
"dry run, report only" if DRY_RUN else "opted in, releasing",
)
row = code_rows_by_code[item["code"]]
release_code(token, row, item["orphanedOrderIds"])
log.info("Done. %d code(s) flagged as stuck redeemed by only-cancelled orders.", len(releasable))
print(json.dumps(releasable, indent=2))
return releasable
if __name__ == "__main__":
run()
/**
* Find Shopware 6 promotion codes stuck redeemed by only-cancelled orders, and report them, safely.
*
* Shopware 6 tracks individual promotion code redemption inside the promotion-individual-code
* entity's payload JSON field, in a redeemed list keyed by order and orderCustomer, written by
* the PromotionRedeemUpdater subscriber at order placement. No subscriber is wired to the
* order's cancel state-machine transition to remove that entry, so the code stays marked
* redeemed forever even after the only order that used it is cancelled. There is no documented
* release-code admin API action, so this script reports every code where every redeemed order
* is cancelled, and only writes a trimmed payload behind an explicit opt-in flag, after printing
* the exact before and after JSON. Run on a schedule or on demand. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/promotion-code-not-released-on-cancel/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
/**
* codes: Array<{ code, promotionId, payload: { redeemed: Array<{ orderId, orderCustomerId, redeemedAt }> } }>
* orders: Map<orderId, { id, stateMachineTechnicalName }>
* Returns Array<{ code, promotionId, orphanedOrderIds }>
*/
export function decideOrphanedRedeemedCodes(codes, orders) {
const releasable = [];
for (const entry of codes) {
const redeemed = entry.payload?.redeemed || [];
if (redeemed.length === 0) continue;
const orphanedIds = [];
for (const row of redeemed) {
const order = orders.get(row.orderId);
if (order && order.stateMachineTechnicalName === "cancelled") {
orphanedIds.push(row.orderId);
}
}
if (orphanedIds.length === redeemed.length) {
releasable.push({
code: entry.code,
promotionId: entry.promotionId,
orphanedOrderIds: orphanedIds,
});
}
}
return releasable;
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchCancelledOrdersWithPromotions(token, page = 1, limit = 200) {
const body = {
page,
limit,
filter: [{ type: "equals",
field: "stateMachineState.technicalName",
value: "cancelled" }],
associations: { lineItems: {}, transactions: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
function codesUsedByOrder(order) {
const lineItems = order.lineItems || [];
const codes = new Set();
for (const item of lineItems) {
if (item.type !== "promotion") continue;
const code = item.payload?.code;
if (code) codes.add(code);
}
return codes;
}
async function findIndividualCode(token, code) {
const body = { filter: [{ type: "equals", field: "code", value: code }] };
const data = await api("POST", "/api/search/promotion-individual-code", token, body);
const rows = data.data || [];
return rows.length ? rows[0] : null;
}
async function fetchOrderStates(token, orderIds) {
if (orderIds.length === 0) return new Map();
const body = {
filter: [{ type: "equalsAny", field: "id", value: orderIds }],
associations: { stateMachineState: {} },
};
const data = await api("POST", "/api/search/order", token, body);
const map = new Map();
for (const row of data.data || []) {
map.set(row.id, {
id: row.id,
stateMachineTechnicalName: row.stateMachineState?.technicalName,
});
}
return map;
}
async function releaseCode(token, individualCodeRow, orphanedOrderIds) {
const currentPayload = individualCodeRow.payload || {};
const currentRedeemed = currentPayload.redeemed || [];
const orphanedSet = new Set(orphanedOrderIds);
const kept = currentRedeemed.filter((row) => !orphanedSet.has(row.orderId));
const newPayload = { ...currentPayload, redeemed: kept };
console.log(
`Would PATCH promotion-individual-code/${individualCodeRow.id} payload from ${JSON.stringify(currentPayload)} to ${JSON.stringify(newPayload)}`
);
if (DRY_RUN) return newPayload;
await api("PATCH", `/api/promotion-individual-code/${individualCodeRow.id}`, token, {
payload: newPayload,
});
return newPayload;
}
export async function run() {
const token = await getToken();
const codesSeen = new Set();
let page = 1;
while (true) {
const data = await searchCancelledOrdersWithPromotions(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
for (const order of rows) {
for (const code of codesUsedByOrder(order)) codesSeen.add(code);
}
if (page * 200 >= (data.total || 0)) break;
page++;
}
const codeEntries = [];
const codeRowsByCode = new Map();
const allOrderIds = new Set();
for (const code of codesSeen) {
const row = await findIndividualCode(token, code);
if (!row) continue;
codeRowsByCode.set(code, row);
const payload = row.payload || {};
const redeemed = payload.redeemed || [];
for (const r of redeemed) {
if (r.orderId) allOrderIds.add(r.orderId);
}
codeEntries.push({ code, promotionId: row.promotionId, payload });
}
const ordersById = await fetchOrderStates(token, [...allOrderIds]);
const releasable = decideOrphanedRedeemedCodes(codeEntries, ordersById);
for (const item of releasable) {
console.warn(
`Code ${item.code} is stuck redeemed, every order (${item.orphanedOrderIds.join(", ")}) is cancelled. ${DRY_RUN ? "dry run, report only" : "opted in, releasing"}`
);
const row = codeRowsByCode.get(item.code);
await releaseCode(token, row, item.orphanedOrderIds);
}
console.log(`Done. ${releasable.length} code(s) flagged as stuck redeemed by only-cancelled orders.`);
console.log(JSON.stringify(releasable, null, 2));
return releasable;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which codes get treated as safe to release. Because decide_orphaned_redeemed_codes is pure, no network and no Shopware instance is needed. It just takes a plain list of codes and an order-state lookup and checks the answer, including the case where an order id cannot be found at all.
from release_orphaned_promotion_codes import decide_orphaned_redeemed_codes
def code(code_value, redeemed, promotion_id="promo-1"):
return {"code": code_value, "promotionId": promotion_id, "payload": {"redeemed": redeemed}}
def redemption(order_id, order_customer_id="cust-1", redeemed_at="2026-07-01T00:00:00"):
return {"orderId": order_id, "orderCustomerId": order_customer_id, "redeemedAt": redeemed_at}
def order(order_id, state):
return {"id": order_id, "stateMachineTechnicalName": state}
def test_empty_redeemed_list_is_skipped():
codes = [code("CODE1", [])]
assert decide_orphaned_redeemed_codes(codes, {}) == []
def test_releasable_when_only_order_is_cancelled():
codes = [code("CODE1", [redemption("order-1")])]
orders = {"order-1": order("order-1", "cancelled")}
result = decide_orphaned_redeemed_codes(codes, orders)
assert result == [{"code": "CODE1", "promotionId": "promo-1", "orphanedOrderIds": ["order-1"]}]
def test_not_releasable_when_order_still_active():
codes = [code("CODE1", [redemption("order-1")])]
orders = {"order-1": order("order-1", "open")}
assert decide_orphaned_redeemed_codes(codes, orders) == []
def test_not_releasable_when_one_of_two_orders_still_active():
codes = [code("CODE1", [redemption("order-1"), redemption("order-2")])]
orders = {
"order-1": order("order-1", "cancelled"),
"order-2": order("order-2", "open"),
}
assert decide_orphaned_redeemed_codes(codes, orders) == []
def test_releasable_when_all_of_multiple_orders_cancelled():
codes = [code("CODE1", [redemption("order-1"), redemption("order-2")])]
orders = {
"order-1": order("order-1", "cancelled"),
"order-2": order("order-2", "cancelled"),
}
result = decide_orphaned_redeemed_codes(codes, orders)
assert result == [{"code": "CODE1", "promotionId": "promo-1", "orphanedOrderIds": ["order-1", "order-2"]}]
def test_missing_order_is_not_treated_as_orphaned():
codes = [code("CODE1", [redemption("missing-order")])]
assert decide_orphaned_redeemed_codes(codes, {}) == []
def test_missing_order_alongside_cancelled_blocks_release():
codes = [code("CODE1", [redemption("order-1"), redemption("missing-order")])]
orders = {"order-1": order("order-1", "cancelled")}
assert decide_orphaned_redeemed_codes(codes, orders) == []
def test_multiple_codes_only_fully_orphaned_one_is_returned():
codes = [
code("STUCK", [redemption("order-1")], promotion_id="promo-a"),
code("LIVE", [redemption("order-2")], promotion_id="promo-b"),
]
orders = {
"order-1": order("order-1", "cancelled"),
"order-2": order("order-2", "in_progress"),
}
result = decide_orphaned_redeemed_codes(codes, orders)
assert result == [{"code": "STUCK", "promotionId": "promo-a", "orphanedOrderIds": ["order-1"]}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideOrphanedRedeemedCodes } from "./release-orphaned-promotion-codes.js";
const code = (codeValue, redeemed, promotionId = "promo-1") => ({
code: codeValue,
promotionId,
payload: { redeemed },
});
const redemption = (orderId, orderCustomerId = "cust-1", redeemedAt = "2026-07-01T00:00:00") => ({
orderId,
orderCustomerId,
redeemedAt,
});
const order = (id, state) => ({ id, stateMachineTechnicalName: state });
test("empty redeemed list is skipped", () => {
const codes = [code("CODE1", [])];
assert.deepEqual(decideOrphanedRedeemedCodes(codes, new Map()), []);
});
test("releasable when only order is cancelled", () => {
const codes = [code("CODE1", [redemption("order-1")])];
const orders = new Map([["order-1", order("order-1", "cancelled")]]);
const result = decideOrphanedRedeemedCodes(codes, orders);
assert.deepEqual(result, [{ code: "CODE1", promotionId: "promo-1", orphanedOrderIds: ["order-1"] }]);
});
test("not releasable when order still active", () => {
const codes = [code("CODE1", [redemption("order-1")])];
const orders = new Map([["order-1", order("order-1", "open")]]);
assert.deepEqual(decideOrphanedRedeemedCodes(codes, orders), []);
});
test("not releasable when one of two orders still active", () => {
const codes = [code("CODE1", [redemption("order-1"), redemption("order-2")])];
const orders = new Map([
["order-1", order("order-1", "cancelled")],
["order-2", order("order-2", "open")],
]);
assert.deepEqual(decideOrphanedRedeemedCodes(codes, orders), []);
});
test("releasable when all of multiple orders cancelled", () => {
const codes = [code("CODE1", [redemption("order-1"), redemption("order-2")])];
const orders = new Map([
["order-1", order("order-1", "cancelled")],
["order-2", order("order-2", "cancelled")],
]);
const result = decideOrphanedRedeemedCodes(codes, orders);
assert.deepEqual(result, [{ code: "CODE1", promotionId: "promo-1", orphanedOrderIds: ["order-1", "order-2"] }]);
});
test("missing order is not treated as orphaned", () => {
const codes = [code("CODE1", [redemption("missing-order")])];
assert.deepEqual(decideOrphanedRedeemedCodes(codes, new Map()), []);
});
test("missing order alongside cancelled blocks release", () => {
const codes = [code("CODE1", [redemption("order-1"), redemption("missing-order")])];
const orders = new Map([["order-1", order("order-1", "cancelled")]]);
assert.deepEqual(decideOrphanedRedeemedCodes(codes, orders), []);
});
test("multiple codes, only fully orphaned one is returned", () => {
const codes = [
code("STUCK", [redemption("order-1")], "promo-a"),
code("LIVE", [redemption("order-2")], "promo-b"),
];
const orders = new Map([
["order-1", order("order-1", "cancelled")],
["order-2", order("order-2", "in_progress")],
]);
const result = decideOrphanedRedeemedCodes(codes, orders);
assert.deepEqual(result, [{ code: "STUCK", promotionId: "promo-a", orphanedOrderIds: ["order-1"] }]);
});
Case studies
A launch code got locked out by a payment gateway timeout
A skincare brand handed out one-time codes to a limited list of newsletter subscribers for a launch. One subscriber's card issuer timed out mid-checkout, the order was placed and then cancelled automatically after the failed payment, and the subscriber tried the same code again a few minutes later. It came back as already used, even though no order of theirs had ever actually gone through.
Running the script found the code, confirmed its only redeemed entry pointed at the cancelled order, and reported it. Support reviewed the single-row report, agreed it was safe, and reissued the code to the same subscriber by hand while the team decided whether to automate the opt-in release for future launches.
A batch of QA orders quietly ate a promotion's usage limit
A QA team tested checkout flows against a promotion with individually generated codes, then cancelled every test order once the run finished. Weeks later, marketing noticed the promotion's usage counters looked oddly high for how few real customers had used it, and a handful of codes on the list would not apply for real shoppers.
The script's report tied the exact codes back to the cancelled QA orders, all with fully orphaned redemption ledgers, and marketing confirmed each one was genuinely a test artifact before opting in to release them, restoring the codes without guessing which orders had been real.
After this runs on a schedule, a promotion code that lost its only order to a cancellation shows up in a report with the exact orphaned order ids next to it, not a guess. Nothing gets released automatically, and a code with even one live order stays completely untouched. Only when a human reviews the report and opts in does the script rewrite the payload, and even then it is always current-minus-orphaned, computed from a fresh read, never a blind overwrite.
FAQ
Why does a Shopware 6 promotion code stay redeemed after the order that used it is cancelled?
Shopware 6 writes individual code redemption into the promotion-individual-code entity's payload JSON field, in a redeemed list keyed by order and orderCustomer, at order placement through the PromotionRedeemUpdater subscriber. There is no matching subscriber wired to the order cancel state-machine transition that removes the entry from that payload, so the redemption record survives even though the only order that consumed the code is dead.
Can I fix this by writing a redeemed count or boolean back on the promotion itself?
No. There is no boolean or state-machine field to flip. The actual redemption ledger for an individual code lives inside promotion-individual-code.payload, an internal JSON structure maintained by PromotionRedeemUpdater, and the parent promotion's own aggregate counters read from the same orphaned entries. Any fix has to read and rewrite that payload directly, not a separate flag.
Is it safe to automatically clear every redeemed entry that points at a cancelled order?
Treat it as report first. Shopware has no documented release-code admin API action, so the safe pattern is to flag a code as releasable only when every redeemed entry for it points at a cancelled order, leaving any code with even one still-active order alone, and to only write the trimmed payload behind an explicit opt-in flag after a dry run has printed the exact before and after JSON.
Related field notes
Citations
On the problem:
- Shopware GitHub Issues: Release promo code after cancelation. github.com/shopware/shopware/issues/4382
- Shopware GitHub Issues: Release Promocodes after cancellation of order. github.com/shopware/shopware/issues/5830
- Shopware Documentation: Marketing, Promotions. docs.shopware.com marketing/promotions
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com using-the-state-machine
- Shopware Admin API Reference: Order. shopware.stoplight.io admin-api order
- Shopware Admin API Reference: Promotion. shopware.stoplight.io admin-api promotion
Stuck on a tricky one?
If you have a problem in Shopware order states, stock, message queue, or data integrity that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this free up a stuck code?
If this saved you from a customer complaint over a code that looked used but was not, 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