Diagnostic Order Data Integrity
Shopware 6 order totals not recalculated after manual line item edits
A support agent bumps a quantity on an order-line-item, or removes a row, straight through the Administration or a PATCH to the Admin API. The edit saves without complaint. But the order's amountTotal, amountNet, and any promotion discount rows never move, because nothing told Shopware to recompute them. The order now shows a total that does not match its own line items, and the mismatch sits there quietly until someone reconciles the books. Here is why it happens and a small script that finds the stale orders and repairs them with Shopware's own recalculation pipeline.
In Shopware 6, amountTotal, amountNet, and positionPrice on the order entity are ApiAware but WriteProtected fields computed by the cart and price calculation pipeline. They are only ever recomputed when the explicit recalculate action runs, exposed as POST /api/_action/order/{orderId}/recalculate. A manual PATCH to an order-line-item's quantity or price, or adding or removing one in the Administration without triggering a full recalculation, does not call that pipeline, so totals and promotion discount rows go stale. Run a small Python or Node.js script that searches open and in-progress orders, independently sums their line items and shipping costs to compute the expected total, flags any order whose stored amountTotal disagrees by more than a cent or that carries an orphaned promotion row, and repairs it by calling the order's own recalculate action, never by PATCHing the totals directly. Full code, tests, and sources are below.
The problem in plain words
An order in Shopware 6 is not a live cart. Once it is placed, its amountTotal, amountNet, and line item totals are a snapshot, written once by the checkout's cart and price calculation and then left alone. That snapshot is deliberately write-protected in the entity definition, because Shopware wants totals to only ever come out of its own calculation pipeline, never out of a raw field write.
The trouble is that editing an order later is a very normal thing to do. A merchant adds a missed item, a support agent corrects a quantity, someone removes a row that should never have shipped. All of those are ordinary PATCH calls or clicks in the Administration, and every one of them succeeds, because the line item itself is not write-protected. What does not happen automatically is a call back into the price calculation pipeline. The order's totals, its tax split, and any automatic promotion or discount line items are only reapplied at the moment RecalculationService::recalculate runs, and a plain edit to a line item never invokes it on its own.
Why it happens
This is a long-standing, confirmed pattern in the platform rather than a one-off bug in a single store. A few things make it easy to hit:
- The Administration lets a user change a line item's quantity or price, or add and remove rows, on an already-placed order without always forcing a full recalculation afterward.
- The Admin API exposes
order-line-itemas its own writable resource, so a script or integration can PATCH it directly with no obligation to also call the order's recalculate action. - Promotion and automatic discount line items are only reapplied at initial cart calculation. Adding, removing, or changing the quantity of items on an existing order does not reapply the promotion logic, so a discount row can be left stale with an outdated value, or silently excluded from the total while the line item itself still sits on the order.
- Nothing surfaces the mismatch on its own. The order still loads, still shows a number, and the discrepancy is invisible until someone reconciles the stored total against the line items by hand.
Shopware protected these fields on purpose, because it wants order totals to only ever come out of its own price calculation pipeline. So the fix is never to PATCH amountTotal or amountNet back into agreement by hand, that would only paper over the number without fixing the tax split or the promotion rows underneath it. The fix is to detect which orders disagree with their own line items, then hand each one back to the same recalculation pipeline that built it in the first place.
The fix, as a flow
We never touch amountTotal, amountNet, or any line item field directly. The script searches open and in-progress orders with their line items and transactions, independently sums what the order should total, compares that against the stored value, and when they disagree by more than a cent, or a promotion row looks orphaned, calls Shopware's own POST /api/_action/order/{orderId}/recalculate to rebuild the order from scratch.
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 the search, the recalculate action, and the confirmation read.
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) : {};
}
Pull candidate orders
Search order restricted to non-final states, since completed and cancelled orders are out of scope for repair, including the lineItems and transactions associations so each hit carries everything the decision needs.
def find_candidate_orders(token):
body = {
"filter": [
{
"type": "equalsAny",
"field": "stateMachineState.technicalName",
"value": ["open", "in_progress"],
}
],
"associations": {
"lineItems": {},
"transactions": {"associations": {"stateMachineState": {}}},
},
"sort": [{"field": "orderDateTime", "order": "DESC"}],
"total-count-mode": 1,
"limit": 200,
}
return api("POST", "/api/search/order", token, body)
async function findCandidateOrders(token) {
const body = {
filter: [
{
type: "equalsAny",
field: "stateMachineState.technicalName",
value: ["open", "in_progress"],
},
],
associations: {
lineItems: {},
transactions: { associations: { stateMachineState: {} } },
},
sort: [{ field: "orderDateTime", order: "DESC" }],
"total-count-mode": 1,
limit: 200,
};
return api("POST", "/api/search/order", token, body);
}
Decide, with one pure function
Keep the arithmetic and the decision entirely out of the network code. This function takes an already-fetched order with its line items and shipping costs, sums the non-promotion line items plus shipping plus the promotion rows, compares that against the stored amountTotal within a cent, and separately flags promotion-type line items whose total looks orphaned. It never calls the API and never mutates anything.
def find_stale_order_totals(order, epsilon=0.01):
line_items = order.get("lineItems") or []
shipping_total = (order.get("shippingCosts") or {}).get("totalPrice", 0) or 0
non_promotion_total = sum(
li.get("totalPrice", 0) or 0 for li in line_items if li.get("type") != "promotion"
)
promotion_total = sum(
li.get("totalPrice", 0) or 0 for li in line_items if li.get("type") == "promotion"
)
computed_total = non_promotion_total + shipping_total + promotion_total
stored_total = order.get("amountTotal", 0) or 0
delta = computed_total - stored_total
orphaned_promotion_line_item_ids = []
for li in line_items:
if li.get("type") != "promotion":
continue
total_price = li.get("totalPrice", 0) or 0
quantity = li.get("quantity", 0) or 0
unit_price = li.get("unitPrice", 0) or 0
expected = quantity * unit_price
if total_price == 0 or abs(expected - total_price) > epsilon:
orphaned_promotion_line_item_ids.append(li["id"])
return {
"orderId": order["id"],
"isStale": abs(delta) > epsilon,
"computedTotal": computed_total,
"storedTotal": stored_total,
"delta": delta,
"orphanedPromotionLineItemIds": orphaned_promotion_line_item_ids,
}
export function findStaleOrderTotals(order, epsilon = 0.01) {
const lineItems = order.lineItems || [];
const shippingTotal = order.shippingCosts?.totalPrice || 0;
const nonPromotionTotal = lineItems
.filter((li) => li.type !== "promotion")
.reduce((sum, li) => sum + (li.totalPrice || 0), 0);
const promotionTotal = lineItems
.filter((li) => li.type === "promotion")
.reduce((sum, li) => sum + (li.totalPrice || 0), 0);
const computedTotal = nonPromotionTotal + shippingTotal + promotionTotal;
const storedTotal = order.amountTotal || 0;
const delta = computedTotal - storedTotal;
const orphanedPromotionLineItemIds = lineItems
.filter((li) => li.type === "promotion")
.filter((li) => {
const totalPrice = li.totalPrice || 0;
const expected = (li.quantity || 0) * (li.unitPrice || 0);
return totalPrice === 0 || Math.abs(expected - totalPrice) > epsilon;
})
.map((li) => li.id);
return {
orderId: order.id,
isStale: Math.abs(delta) > epsilon,
computedTotal,
storedTotal,
delta,
orphanedPromotionLineItemIds,
};
}
Repair with the recalculate action, never a direct PATCH
When an order is flagged stale, call POST /api/_action/order/{orderId}/recalculate. This runs RecalculationService::recalculate, which rebuilds a cart from the persisted order, reruns price, tax, and promotion calculation, and persists the corrected totals and line items on its own. We never PATCH amountTotal, amountNet, or a line item field directly, those are write-protected for a reason.
def recalculate_order(token, order_id):
api("POST", f"/api/_action/order/{order_id}/recalculate", token)
def confirm_order_total(token, order_id, expected_total, epsilon=0.01):
data = api("GET", f"/api/order/{order_id}", token)
amount_total = data.get("data", {}).get("attributes", {}).get("amountTotal", 0)
return abs(amount_total - expected_total) <= epsilon
async function recalculateOrder(token, orderId) {
await api("POST", `/api/_action/order/${orderId}/recalculate`, token);
}
async function confirmOrderTotal(token, orderId, expectedTotal, epsilon = 0.01) {
const data = await api("GET", `/api/order/${orderId}`, token);
const amountTotal = data?.data?.attributes?.amountTotal || 0;
return Math.abs(amountTotal - expectedTotal) <= epsilon;
}
Wire it together with a dry run guard
The loop ties every piece together. In dry run, it only logs the order id and the computed-versus-stored delta that would be recalculated. With DRY_RUN=false, it calls recalculate for each flagged order, then re-fetches the order to confirm amountTotal now matches the computed sum before marking it resolved. If recalculate still leaves a mismatch, a known edge case with certain custom promotion rules, it falls back to a flagged report for manual merchant review instead of forcing a value.
Always start with DRY_RUN=true. Recalculate is the platform's own repair mechanism, so it is far safer than any manual PATCH, but it still changes a placed order's numbers. Review the dry run list, confirm the deltas look right, then switch it off to let it 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 is safe to run again and again because it only recalculates orders whose stored total disagrees with its own line items, and it always confirms the result before calling an order resolved.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 orders whose totals went stale after a manual line item edit, and repair them, safely.
amountTotal, amountNet, and positionPrice on the order entity are ApiAware but WriteProtected
fields computed by the cart and price calculation pipeline. They are only recomputed when the
explicit recalculate action runs. A manual PATCH to an order-line-item's quantity or price, or
adding or removing one, does not implicitly invoke it, so totals and promotion discount rows
can drift out of sync with the order's own line items. This script finds the mismatches by
independently summing each order's line items and shipping costs, and repairs only by calling
Shopware's own recalculate action, never by writing amountTotal or a line item field directly.
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_stale_order_totals")
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"
EPSILON = float(os.environ.get("EPSILON", "0.01"))
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_candidate_orders(token):
body = {
"filter": [
{
"type": "equalsAny",
"field": "stateMachineState.technicalName",
"value": ["open", "in_progress"],
}
],
"associations": {
"lineItems": {},
"transactions": {"associations": {"stateMachineState": {}}},
},
"sort": [{"field": "orderDateTime", "order": "DESC"}],
"total-count-mode": 1,
"limit": 200,
}
return api("POST", "/api/search/order", token, body)
def find_stale_order_totals(order, epsilon=0.01):
line_items = order.get("lineItems") or []
shipping_total = (order.get("shippingCosts") or {}).get("totalPrice", 0) or 0
non_promotion_total = sum(
li.get("totalPrice", 0) or 0 for li in line_items if li.get("type") != "promotion"
)
promotion_total = sum(
li.get("totalPrice", 0) or 0 for li in line_items if li.get("type") == "promotion"
)
computed_total = non_promotion_total + shipping_total + promotion_total
stored_total = order.get("amountTotal", 0) or 0
delta = computed_total - stored_total
orphaned_promotion_line_item_ids = []
for li in line_items:
if li.get("type") != "promotion":
continue
total_price = li.get("totalPrice", 0) or 0
quantity = li.get("quantity", 0) or 0
unit_price = li.get("unitPrice", 0) or 0
expected = quantity * unit_price
if total_price == 0 or abs(expected - total_price) > epsilon:
orphaned_promotion_line_item_ids.append(li["id"])
return {
"orderId": order["id"],
"isStale": abs(delta) > epsilon,
"computedTotal": computed_total,
"storedTotal": stored_total,
"delta": delta,
"orphanedPromotionLineItemIds": orphaned_promotion_line_item_ids,
}
def recalculate_order(token, order_id):
api("POST", f"/api/_action/order/{order_id}/recalculate", token)
def confirm_order_total(token, order_id, expected_total, epsilon=0.01):
data = api("GET", f"/api/order/{order_id}", token)
amount_total = data.get("data", {}).get("attributes", {}).get("amountTotal", 0)
return abs(amount_total - expected_total) <= epsilon
def run():
token = get_token()
data = find_candidate_orders(token)
orders = data.get("data", [])
repaired = 0
flagged = 0
for row in orders:
attrs = row.get("attributes", row)
order = {
"id": row["id"],
"amountTotal": attrs.get("amountTotal", 0),
"shippingCosts": attrs.get("shippingCosts") or {},
"lineItems": attrs.get("lineItems") or [],
}
decision = find_stale_order_totals(order, EPSILON)
if not decision["isStale"] and not decision["orphanedPromotionLineItemIds"]:
continue
order_number = attrs.get("orderNumber", "?")
log.info(
"Order %s (%s) stale. stored=%.2f computed=%.2f delta=%.2f orphanedPromotions=%s. %s",
order_number, decision["orderId"], decision["storedTotal"], decision["computedTotal"],
decision["delta"], decision["orphanedPromotionLineItemIds"],
"would recalculate" if DRY_RUN else "recalculating",
)
if DRY_RUN:
flagged += 1
continue
recalculate_order(token, decision["orderId"])
if confirm_order_total(token, decision["orderId"], decision["computedTotal"], EPSILON):
repaired += 1
else:
log.warning(
"Order %s still mismatched after recalculate. Flagging for manual review.",
order_number,
)
flagged += 1
log.info(
"Done. %d order(s) %s, %d flagged for manual review.",
repaired, "to recalculate" if DRY_RUN else "recalculated", flagged,
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 orders whose totals went stale after a manual line item edit, and repair them, safely.
*
* amountTotal, amountNet, and positionPrice on the order entity are ApiAware but WriteProtected
* fields computed by the cart and price calculation pipeline. They are only recomputed when the
* explicit recalculate action runs. A manual PATCH to an order-line-item's quantity or price, or
* adding or removing one, does not implicitly invoke it, so totals and promotion discount rows
* can drift out of sync with the order's own line items. This script finds the mismatches by
* independently summing each order's line items and shipping costs, and repairs only by calling
* Shopware's own recalculate action, never by writing amountTotal or a line item field directly.
* 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";
const EPSILON = Number(process.env.EPSILON || 0.01);
export function findStaleOrderTotals(order, epsilon = 0.01) {
const lineItems = order.lineItems || [];
const shippingTotal = order.shippingCosts?.totalPrice || 0;
const nonPromotionTotal = lineItems
.filter((li) => li.type !== "promotion")
.reduce((sum, li) => sum + (li.totalPrice || 0), 0);
const promotionTotal = lineItems
.filter((li) => li.type === "promotion")
.reduce((sum, li) => sum + (li.totalPrice || 0), 0);
const computedTotal = nonPromotionTotal + shippingTotal + promotionTotal;
const storedTotal = order.amountTotal || 0;
const delta = computedTotal - storedTotal;
const orphanedPromotionLineItemIds = lineItems
.filter((li) => li.type === "promotion")
.filter((li) => {
const totalPrice = li.totalPrice || 0;
const expected = (li.quantity || 0) * (li.unitPrice || 0);
return totalPrice === 0 || Math.abs(expected - totalPrice) > epsilon;
})
.map((li) => li.id);
return {
orderId: order.id,
isStale: Math.abs(delta) > epsilon,
computedTotal,
storedTotal,
delta,
orphanedPromotionLineItemIds,
};
}
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 findCandidateOrders(token) {
const body = {
filter: [
{
type: "equalsAny",
field: "stateMachineState.technicalName",
value: ["open", "in_progress"],
},
],
associations: {
lineItems: {},
transactions: { associations: { stateMachineState: {} } },
},
sort: [{ field: "orderDateTime", order: "DESC" }],
"total-count-mode": 1,
limit: 200,
};
return api("POST", "/api/search/order", token, body);
}
async function recalculateOrder(token, orderId) {
await api("POST", `/api/_action/order/${orderId}/recalculate`, token);
}
async function confirmOrderTotal(token, orderId, expectedTotal, epsilon = 0.01) {
const data = await api("GET", `/api/order/${orderId}`, token);
const amountTotal = data?.data?.attributes?.amountTotal || 0;
return Math.abs(amountTotal - expectedTotal) <= epsilon;
}
export async function run() {
const token = await getToken();
const data = await findCandidateOrders(token);
const orders = data.data || [];
let repaired = 0;
let flagged = 0;
for (const row of orders) {
const attrs = row.attributes || row;
const order = {
id: row.id,
amountTotal: attrs.amountTotal || 0,
shippingCosts: attrs.shippingCosts || {},
lineItems: attrs.lineItems || [],
};
const decision = findStaleOrderTotals(order, EPSILON);
if (!decision.isStale && decision.orphanedPromotionLineItemIds.length === 0) continue;
const orderNumber = attrs.orderNumber || "?";
console.log(
`Order ${orderNumber} (${decision.orderId}) stale. stored=${decision.storedTotal.toFixed(2)} computed=${decision.computedTotal.toFixed(2)} delta=${decision.delta.toFixed(2)} orphanedPromotions=${JSON.stringify(decision.orphanedPromotionLineItemIds)}. ${DRY_RUN ? "would recalculate" : "recalculating"}`
);
if (DRY_RUN) {
flagged++;
continue;
}
await recalculateOrder(token, decision.orderId);
const confirmed = await confirmOrderTotal(token, decision.orderId, decision.computedTotal, EPSILON);
if (confirmed) {
repaired++;
} else {
console.warn(`Order ${orderNumber} still mismatched after recalculate. Flagging for manual review.`);
flagged++;
}
}
console.log(`Done. ${repaired} order(s) ${DRY_RUN ? "to recalculate" : "recalculated"}, ${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 placed order gets sent back through the recalculation pipeline. Because find_stale_order_totals is pure, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.
from repair_stale_order_totals import find_stale_order_totals
def line_item(**over):
base = {"id": "li-1", "type": "product", "totalPrice": 100.0, "quantity": 2, "unitPrice": 50.0}
base.update(over)
return base
def order(**over):
base = {
"id": "order-1",
"amountTotal": 110.0,
"shippingCosts": {"totalPrice": 10.0},
"lineItems": [line_item()],
}
base.update(over)
return base
def test_not_stale_when_totals_agree():
result = find_stale_order_totals(order())
assert result["isStale"] is False
assert result["computedTotal"] == 110.0
assert result["orphanedPromotionLineItemIds"] == []
def test_stale_when_stored_total_is_wrong():
result = find_stale_order_totals(order(amountTotal=95.0))
assert result["isStale"] is True
assert round(result["delta"], 2) == 15.0
def test_promotion_line_item_is_subtracted():
promo = line_item(id="li-promo", type="promotion", totalPrice=-20.0, quantity=1, unitPrice=-20.0)
o = order(amountTotal=90.0, lineItems=[line_item(), promo])
result = find_stale_order_totals(o)
assert result["computedTotal"] == 90.0
assert result["isStale"] is False
def test_orphaned_promotion_with_zero_total_is_flagged():
promo = line_item(id="li-promo", type="promotion", totalPrice=0.0, quantity=1, unitPrice=-20.0)
o = order(amountTotal=110.0, lineItems=[line_item(), promo])
result = find_stale_order_totals(o)
assert "li-promo" in result["orphanedPromotionLineItemIds"]
def test_within_epsilon_is_not_stale():
result = find_stale_order_totals(order(amountTotal=110.005), epsilon=0.01)
assert result["isStale"] is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleOrderTotals } from "./repair-stale-order-totals.js";
const lineItem = (over = {}) => ({
id: "li-1",
type: "product",
totalPrice: 100.0,
quantity: 2,
unitPrice: 50.0,
...over,
});
const order = (over = {}) => ({
id: "order-1",
amountTotal: 110.0,
shippingCosts: { totalPrice: 10.0 },
lineItems: [lineItem()],
...over,
});
test("not stale when totals agree", () => {
const result = findStaleOrderTotals(order());
assert.equal(result.isStale, false);
assert.equal(result.computedTotal, 110.0);
assert.deepEqual(result.orphanedPromotionLineItemIds, []);
});
test("stale when stored total is wrong", () => {
const result = findStaleOrderTotals(order({ amountTotal: 95.0 }));
assert.equal(result.isStale, true);
assert.equal(Math.round(result.delta * 100) / 100, 15.0);
});
test("promotion line item is subtracted", () => {
const promo = lineItem({ id: "li-promo", type: "promotion", totalPrice: -20.0, quantity: 1, unitPrice: -20.0 });
const o = order({ amountTotal: 90.0, lineItems: [lineItem(), promo] });
const result = findStaleOrderTotals(o);
assert.equal(result.computedTotal, 90.0);
assert.equal(result.isStale, false);
});
test("orphaned promotion with zero total is flagged", () => {
const promo = lineItem({ id: "li-promo", type: "promotion", totalPrice: 0.0, quantity: 1, unitPrice: -20.0 });
const o = order({ amountTotal: 110.0, lineItems: [lineItem(), promo] });
const result = findStaleOrderTotals(o);
assert.ok(result.orphanedPromotionLineItemIds.includes("li-promo"));
});
test("within epsilon is not stale", () => {
const result = findStaleOrderTotals(order({ amountTotal: 110.005 }), 0.01);
assert.equal(result.isStale, false);
});
Case studies
A support agent removed a line item and the discount stayed
A merchant's support team removed a duplicate line item from an open order directly in the Administration, without running a recalculation afterward. The order still showed a ten percent promotion line, but that discount had been calculated against the item that was just deleted, so the stored total no longer matched anything the order actually contained.
Running the script in dry run flagged the order immediately, showing a computed total that disagreed with the stored amountTotal by the exact value of the orphaned discount. With DRY_RUN=false, the recalculate action rebuilt the cart, dropped the stale promotion row, and the order's totals matched its line items again.
A quantity was corrected through the Admin API and the total never moved
An integration corrected a miskeyed quantity on an order-line-item by PATCHing it directly through the Admin API, since that was the only field that needed to change. The PATCH succeeded, the line item showed the right quantity and the right totalPrice, but amountTotal on the order itself stayed exactly where it was before the correction.
The nightly run of the script summed the order's line items and shipping cost, found a mismatch larger than a cent, and called recalculate. It re-fetched the order afterward to confirm the new amountTotal matched the computed sum before counting the order as resolved, so nothing was marked fixed on faith.
After this runs on a schedule, a manual line item edit stops being a silent trap for the order's own numbers. Every open or in-progress order whose stored total disagrees with its line items gets caught, repaired through Shopware's own recalculation pipeline, and confirmed before it is called resolved. The rare case where recalculate cannot resolve a custom promotion rule on its own is reported for a merchant to look at, never forced to a guessed value.
FAQ
Why does editing an order line item leave the Shopware 6 order total wrong?
amountTotal, amountNet, and positionPrice on the order entity are ApiAware but WriteProtected fields computed by the cart and price calculation pipeline. PATCHing an order-line-item's quantity or price, or adding and removing one, does not implicitly run that pipeline, so the stored totals and any promotion discount rows stay exactly as they were before the edit.
Is it safe to PATCH amountTotal or amountNet directly to fix a stale order?
No. Those fields are write-protected and derived, and a manual PATCH will not recompute tax splits or dependent promotion line items correctly. The only safe repair is Shopware's own recalculate action, which rebuilds a cart from the persisted order and reruns price, tax, and promotion calculation before saving the corrected totals.
How do I detect Shopware 6 orders with stale totals using the Admin API?
Search order with its lineItems and transactions associations, then independently sum lineItems totalPrice for non-promotion rows plus shippingCosts totalPrice plus any promotion rows, which carry negative totalPrice. Flag the order when that computed total differs from the stored amountTotal by more than a cent, or when a promotion line item is orphaned with a non-zero total that no longer matches its own quantity times unit price.
Related field notes
Citations
On the problem:
- GitHub Issue: Promotions not recalculated when editing order in administration. github.com/shopware/shopware/issues/3737
- GitHub Issue: Automatically recalculate promotions when making changes to an order. github.com/shopware/shopware/issues/4603
- GitHub Issue: Incorrect order recalculation when using discounts. github.com/shopware/shopware/issues/6851
On the solution:
- Shopware Admin API Reference: the Order entity. shopware.stoplight.io admin-api order
- Shopware Admin API Reference: the OrderLineItem entity. shopware.stoplight.io admin-api order-line-item
- Shopware Developer Documentation: Orders, checkout concept. developer.shopware.com checkout-concept orders
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 straighten out a stale total?
If this saved you a reconciliation headache or a wrong revenue report, 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