Reconciler Order Data Integrity
Shopware 6 order line item references a deleted promotion
Somebody cleans up old promotions in the admin, selects all, and deletes them in one bulk action. Nothing looks wrong at first. Then a support agent tries to edit an old order that used one of those promotions and the save fails outright. The order is frozen. The cause is that the bulk delete skipped the safeguard that normally blocks deleting a promotion still referenced by an order, so the promotion row is gone while the order's line item still points straight at it. Here is why it happens and a small script that finds the dangling references and clears only the ones that carry no financial risk.
Shopware 6 blocks deleting a single promotion that is still referenced by an order_line_item, but that safeguard is skipped when a promotion is removed through the admin's bulk "select all" delete action. The promotion row can then be hard-deleted while orders still hold a line item of type promotion whose promotionId points at nothing. Any later write to that order, a recalculation, an edit, or even an unrelated PATCH, fails because the DAL tries to resolve the missing promotion association. Run a small Python or Node.js script that searches order-line-item for type promotion, cross-checks every promotionId against live promotions, and clears only the dangling ones whose totalPrice is exactly 0. Anything with a non-zero discount amount gets flagged for manual review instead of touched. Full code, tests, and sources are below.
The problem in plain words
A promotion line item in Shopware 6 is not just a discount line with a label. When the line item's type is promotion, it also carries promotionId and referencedId, foreign keys back to the promotion rule that produced it. That link is what lets Shopware show which promotion applied, and it is also what the DAL tries to resolve and join whenever it touches the order aggregate again.
Deleting a single promotion that is still used by an order is supposed to be blocked. Shopware checks for that reference and refuses the delete so the order stays consistent. But the admin also offers a bulk "select all" delete action in the promotion list, and that path does not run the same per-row safeguard. Select every promotion, confirm the bulk delete, and the ones still referenced by historic orders get hard-deleted right along with the rest.
The order itself does not notice right away. Reading it for display mostly still works, because the line item row and its stored payload snapshot are untouched. It is the next write to that order, recalculating totals, editing a quantity, or even an unrelated PATCH that touches the order aggregate, that fails, because the DAL tries to join the now-missing promotion and errors out. The order becomes effectively un-editable in the admin.
Why it happens
This is a gap between the single-row safety check and the bulk action in the admin. A few things make it easy to hit:
- Deleting one promotion at a time correctly blocks the delete when an order still references it. The bulk "select all" delete action does not run that same per-row check before removing the rows.
- The failure is delayed and looks unrelated. A merchant reports "I can't edit this old order" long after the promotion cleanup happened, so nobody connects the two events without digging into
order_line_itemdirectly. - Once the promotion row is gone, it is not just editing the line item that fails. Any write to that order, a recalculation, another line item edit, or an unrelated PATCH that touches the order aggregate, can trip over the dangling association.
- Disabled promotions with certain customer rules have their own related failure mode where editing the order can itself delete the promotion, discussed in shopware/shopware#5007, and a related report on the last line item of an order being deletable and corrupting totals is tracked in shopware/shopware#5148.
Deleting the order line item outright is not the fix, because a line item's price is part of the order's recorded totals and tax, and removing it has to go through cart or order recalculation, not a raw delete. The safe move is narrower: clear the broken foreign key only when the line item's own totalPrice is already 0, since a zero-price discount line has no financial value to lose by nulling its dangling reference. Anything with a real discount amount needs recalculation, not a patch, so we flag it instead of guessing.
The fix, as a flow
We do not touch stateId or run any state machine transition here, this is purely a data integrity repair on the line item's promotion reference. The script searches order-line-item for rows of type promotion, collects every promotionId, and checks which of those ids still exist in promotion. A dangling reference with totalPrice of 0 gets its promotionId and referencedId nulled out. A dangling reference with a non-zero totalPrice gets flagged for a human instead.
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 write
// 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 write
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 both searches and the repair 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 every promotion line item
Search order-line-item for rows where type equals promotion, and include the order association so each hit carries its parent order and order state. We page through with page and limit so a large backlog is handled safely.
def find_promotion_line_items(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "type", "value": "promotion"}],
"associations": {"order": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order-line-item", token, body)
async function findPromotionLineItems(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "type", value: "promotion" }],
associations: { order: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order-line-item", token, body);
}
Check which promotionIds still exist
Collect every distinct promotionId from the hits and search promotion with an equalsAny filter on that list. Any id from the first set that is missing from this result is a dangling reference, the promotion behind it is gone.
def find_existing_promotion_ids(token, promotion_ids):
if not promotion_ids:
return set()
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(promotion_ids)}],
"total-count-mode": 1,
}
data = api("POST", "/api/search/promotion", token, body)
return {p["id"] for p in data.get("data", [])}
async function findExistingPromotionIds(token, promotionIds) {
if (promotionIds.length === 0) return new Set();
const body = {
filter: [{ type: "equalsAny", field: "id", value: promotionIds }],
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/promotion", token, body);
return new Set((data.data || []).map((p) => p.id));
}
Decide, with one pure function
Keep the decision out of the network code entirely. This function takes an already-fetched line item and the set of promotion ids that still exist, and returns one of three plain outcomes: ok when the reference is not dangling at all, safe_to_clear when it is dangling but the line item has no price to lose, or needs_manual_review when it is dangling and carries a real discount amount.
def classify_dangling_promotion_line_item(line_item, existing_promotion_ids):
if line_item.get("type") != "promotion" or line_item.get("promotionId") is None:
return "ok"
if line_item["promotionId"] in existing_promotion_ids:
return "ok"
if line_item.get("totalPrice") == 0:
return "safe_to_clear"
return "needs_manual_review"
export function classifyDanglingPromotionLineItem(lineItem, existingPromotionIds) {
if (lineItem.type !== "promotion" || lineItem.promotionId === null) {
return "ok";
}
if (existingPromotionIds.has(lineItem.promotionId)) {
return "ok";
}
if (lineItem.totalPrice === 0) {
return "safe_to_clear";
}
return "needs_manual_review";
}
Clear, flag, or skip, guarded by dry run and order state
When the decision is safe_to_clear and DRY_RUN is off, PATCH /api/order-line-item/{id} with {"promotionId": null, "referencedId": null, "payload": {}}, which neutralizes the broken foreign key without changing the price. When it is needs_manual_review, log the order id, line item id, and the line item's price definition for a merchant to look at, since a non-zero discount needs order recalculation, not a raw patch. Never touch an order whose stateMachineState.technicalName is cancelled unless someone explicitly asks for it, and never PATCH stateId directly, state changes always go through the state machine transition endpoints.
Always start with DRY_RUN=true. Clearing a dangling promotionId is only safe because the line item's own totalPrice proves there is nothing to lose. Anything with a real discount amount belongs in the review report, not in a direct patch, because removing it safely requires re-running order recalculation.
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 is safe to run again and again because it only clears references with zero financial impact and leaves everything else for a human.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 order line items with a dangling promotionId and repair them, safely.
Shopware 6 blocks deleting a single promotion still referenced by an order line item, but
that safeguard is skipped when a promotion is removed via the admin's bulk "select all"
delete action. The promotion row can then be hard-deleted while orders still hold a line
item of type "promotion" whose promotionId (and referencedId) points at it. Any later write
to that order can fail when the DAL tries to resolve the missing association. This script
finds the dangling references, clears only the ones whose totalPrice is 0 (no financial
impact), and flags everything else for manual review, since a non-zero discount needs order
recalculation, not a direct patch. Run on a schedule. 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("repair_deleted_promotion_references")
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 find_promotion_line_items(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals", "field": "type", "value": "promotion"}],
"associations": {"order": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order-line-item", token, body)
def find_existing_promotion_ids(token, promotion_ids):
if not promotion_ids:
return set()
body = {
"filter": [{"type": "equalsAny", "field": "id", "value": list(promotion_ids)}],
"total-count-mode": 1,
}
data = api("POST", "/api/search/promotion", token, body)
return {p["id"] for p in data.get("data", [])}
def classify_dangling_promotion_line_item(line_item, existing_promotion_ids):
if line_item.get("type") != "promotion" or line_item.get("promotionId") is None:
return "ok"
if line_item["promotionId"] in existing_promotion_ids:
return "ok"
if line_item.get("totalPrice") == 0:
return "safe_to_clear"
return "needs_manual_review"
def clear_dangling_reference(token, line_item_id):
api(
"PATCH",
f"/api/order-line-item/{line_item_id}",
token,
{"promotionId": None, "referencedId": None, "payload": {}},
)
def run():
token = get_token()
cleared = 0
flagged = 0
page = 1
while True:
data = find_promotion_line_items(token, page=page)
rows = data.get("data", [])
if not rows:
break
promotion_ids = {row.get("promotionId") for row in rows if row.get("promotionId")}
existing_ids = find_existing_promotion_ids(token, promotion_ids)
for row in rows:
decision = classify_dangling_promotion_line_item(row, existing_ids)
order = (row.get("order") or {}) if isinstance(row.get("order"), dict) else {}
state = ((order.get("stateMachineState") or {}).get("technicalName")) if isinstance(order.get("stateMachineState"), dict) else None
if decision == "ok":
continue
if state == "cancelled":
log.info("Order %s is cancelled, leaving lineItem %s alone.", order.get("orderNumber", "?"), row["id"])
continue
if decision == "needs_manual_review":
log.warning(
"FLAG order=%s lineItem=%s totalPrice=%s payload=%s reason=non-zero-discount-needs-recalculation",
order.get("orderNumber", "?"), row["id"], row.get("totalPrice"), row.get("payload"),
)
flagged += 1
continue
log.info(
"Order %s lineItem %s has a dangling promotionId with totalPrice 0. %s",
order.get("orderNumber", "?"), row["id"],
"would clear" if DRY_RUN else "clearing",
)
if not DRY_RUN:
clear_dangling_reference(token, row["id"])
cleared += 1
if page * 500 >= data.get("total", 0):
break
page += 1
log.info(
"Done. %d line item(s) %s, %d flagged for manual review.",
cleared, "to clear" if DRY_RUN else "cleared", flagged,
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 order line items with a dangling promotionId and repair them, safely.
*
* Shopware 6 blocks deleting a single promotion still referenced by an order line item, but
* that safeguard is skipped when a promotion is removed via the admin's bulk "select all"
* delete action. The promotion row can then be hard-deleted while orders still hold a line
* item of type "promotion" whose promotionId (and referencedId) points at it. Any later write
* to that order can fail when the DAL tries to resolve the missing association. This script
* finds the dangling references, clears only the ones whose totalPrice is 0 (no financial
* impact), and flags everything else for manual review, since a non-zero discount needs order
* recalculation, not a direct patch. Run on a schedule. Safe to run again and again.
*/
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";
export function classifyDanglingPromotionLineItem(lineItem, existingPromotionIds) {
if (lineItem.type !== "promotion" || lineItem.promotionId === null) {
return "ok";
}
if (existingPromotionIds.has(lineItem.promotionId)) {
return "ok";
}
if (lineItem.totalPrice === 0) {
return "safe_to_clear";
}
return "needs_manual_review";
}
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 findPromotionLineItems(token, page = 1, limit = 500) {
const body = {
page,
limit,
filter: [{ type: "equals", field: "type", value: "promotion" }],
associations: { order: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order-line-item", token, body);
}
async function findExistingPromotionIds(token, promotionIds) {
if (promotionIds.length === 0) return new Set();
const body = {
filter: [{ type: "equalsAny", field: "id", value: promotionIds }],
"total-count-mode": 1,
};
const data = await api("POST", "/api/search/promotion", token, body);
return new Set((data.data || []).map((p) => p.id));
}
async function clearDanglingReference(token, lineItemId) {
await api("PATCH", `/api/order-line-item/${lineItemId}`, token, {
promotionId: null,
referencedId: null,
payload: {},
});
}
export async function run() {
const token = await getToken();
let cleared = 0;
let flagged = 0;
let page = 1;
while (true) {
const data = await findPromotionLineItems(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
const promotionIds = [...new Set(rows.map((row) => row.promotionId).filter(Boolean))];
const existingIds = await findExistingPromotionIds(token, promotionIds);
for (const row of rows) {
const decision = classifyDanglingPromotionLineItem(row, existingIds);
const order = row.order || {};
const state = order.stateMachineState ? order.stateMachineState.technicalName : null;
if (decision === "ok") continue;
if (state === "cancelled") {
console.log(`Order ${order.orderNumber || "?"} is cancelled, leaving lineItem ${row.id} alone.`);
continue;
}
if (decision === "needs_manual_review") {
console.warn(
`FLAG order=${order.orderNumber || "?"} lineItem=${row.id} totalPrice=${row.totalPrice} payload=${JSON.stringify(row.payload)} reason=non-zero-discount-needs-recalculation`
);
flagged++;
continue;
}
console.log(
`Order ${order.orderNumber || "?"} lineItem ${row.id} has a dangling promotionId with totalPrice 0. ${DRY_RUN ? "would clear" : "clearing"}`
);
if (!DRY_RUN) await clearDanglingReference(token, row.id);
cleared++;
}
if (page * 500 >= (data.total || 0)) break;
page++;
}
console.log(`Done. ${cleared} line item(s) ${DRY_RUN ? "to clear" : "cleared"}, ${flagged} flagged for manual review.`);
}
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 whether a historic financial document gets rewritten. Because classify_dangling_promotion_line_item is pure, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.
from repair_deleted_promotion_references import classify_dangling_promotion_line_item
def line_item(**over):
base = {
"id": "li-1",
"type": "promotion",
"promotionId": "promo-1",
"referencedId": "promo-1",
"totalPrice": 0,
"payload": {},
}
base.update(over)
return base
def test_ok_when_promotion_still_exists():
result = classify_dangling_promotion_line_item(line_item(), {"promo-1", "promo-2"})
assert result == "ok"
def test_ok_when_not_promotion_type():
result = classify_dangling_promotion_line_item(line_item(type="product"), set())
assert result == "ok"
def test_ok_when_promotion_id_already_null():
result = classify_dangling_promotion_line_item(line_item(promotionId=None), set())
assert result == "ok"
def test_safe_to_clear_when_dangling_and_zero_price():
result = classify_dangling_promotion_line_item(line_item(), {"promo-2"})
assert result == "safe_to_clear"
def test_needs_manual_review_when_dangling_and_non_zero_price():
result = classify_dangling_promotion_line_item(line_item(totalPrice=-15.0), {"promo-2"})
assert result == "needs_manual_review"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyDanglingPromotionLineItem } from "./repair-deleted-promotion-references.js";
const lineItem = (over = {}) => ({
id: "li-1",
type: "promotion",
promotionId: "promo-1",
referencedId: "promo-1",
totalPrice: 0,
payload: {},
...over,
});
test("ok when promotion still exists", () => {
const result = classifyDanglingPromotionLineItem(lineItem(), new Set(["promo-1", "promo-2"]));
assert.equal(result, "ok");
});
test("ok when not promotion type", () => {
const result = classifyDanglingPromotionLineItem(lineItem({ type: "product" }), new Set());
assert.equal(result, "ok");
});
test("ok when promotionId already null", () => {
const result = classifyDanglingPromotionLineItem(lineItem({ promotionId: null }), new Set());
assert.equal(result, "ok");
});
test("safe_to_clear when dangling and zero price", () => {
const result = classifyDanglingPromotionLineItem(lineItem(), new Set(["promo-2"]));
assert.equal(result, "safe_to_clear");
});
test("needs_manual_review when dangling and non-zero price", () => {
const result = classifyDanglingPromotionLineItem(lineItem({ totalPrice: -15.0 }), new Set(["promo-2"]));
assert.equal(result, "needs_manual_review");
});
Case studies
A bulk delete of expired promotions froze a handful of old orders
A fashion retailer ran a quarterly cleanup of expired seasonal promotions, selected all of them in the admin list, and confirmed the bulk delete in one click. Weeks later, a support agent tried to adjust the shipping address on an old order that had used one of those promotions, and the save failed with no clear reason in the admin UI.
Running the script in dry run found a small set of order line items whose promotionId no longer resolved to a live promotion. Every one of them had a totalPrice of 0, since the discount had already been fully applied and recorded elsewhere on the order, so all of them were safe to clear and unfroze the affected orders on the next run.
One dangling reference still carried a real discount amount
A homeware store deleted a batch of promotions that included one still tied to an active discount line worth a meaningful amount on a recent order. The script's search caught the dangling reference immediately, but the line item's totalPrice was not zero.
Instead of guessing and silently shrinking the order's recorded discount, the script flagged the order id, line item id, and price definition in its log for manual review. The merchant re-ran the order through recalculation with the correct context instead of losing that discount amount to an automated patch.
After this runs, a bulk promotion cleanup stops being a trap that only surfaces months later as an unexplained save failure. The zero-price dangling references get cleared automatically, and every reference with a real discount amount lands in a clear report with the order id, line item id, and price definition, so a merchant can recalculate instead of the script guessing. No historic order total gets rewritten on a hunch.
FAQ
Why does deleting a promotion in bulk break old orders in Shopware 6?
Shopware 6 blocks deleting a single promotion that is still referenced by an order line item, but that safeguard is skipped when the promotion is removed with the admin's bulk select-all delete action. The promotion row can then be hard-deleted while an order still holds a line item of type promotion whose promotionId points at it, so any later write to that order fails when the DAL tries to resolve the missing association.
Is it safe to automatically clear a dangling promotionId on an order line item?
Only when the line item's totalPrice is 0, because nulling promotionId and referencedId at that point has no financial impact. If the line item still carries a non-zero discount amount, clearing the reference would silently change the order's amountTotal, so that case must be flagged for manual review and handled through order recalculation instead of a direct patch.
How do I find order line items with a dangling promotionId using the Shopware Admin API?
Authenticate with POST /api/oauth/token using client_credentials, then POST /api/search/order-line-item with a filter for type equals promotion, including the order association. Collect every promotionId from the hits, then POST /api/search/promotion with an equalsAny filter on those ids. Any promotionId missing from that second result set is a dangling reference.
Related field notes
Citations
On the problem:
- GitHub Issue: Editing an order's promotion fails if its line item references a deleted promotion. github.com/shopware/shopware/issues/11946
- GitHub Issue: Promotions, disabled promotions with customer rules will delete the promotion if you edit the order. github.com/shopware/shopware/issues/5007
- GitHub Issue: Last line item of order can be deleted, leading to incorrect order total. github.com/shopware/shopware/issues/5148
On the solution:
- Shopware Developer Documentation: Search Criteria, admin API filtering with equals and equalsAny. developer.shopware.com search-criteria
- Shopware Developer Documentation: Admin API concepts. developer.shopware.com admin-api
- Shopware Admin API Reference: the OrderLineItem entity. shopware.stoplight.io admin-api order-line-item
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 unfreeze an order?
If this saved you a support ticket or a corrupted order total, 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