Diagnostic Order Data Integrity
Deleting the last line item leaves a Shopware 6 order total stale
A support agent removes every product from an order, maybe to rebuild it from scratch, maybe by mistake while cleaning up a test order. The delete succeeds. The order now shows zero line items. But the total at the top still reads the old amount, sometimes hundreds of dollars, because nothing told Shopware to add it back up. The order is quietly lying about what it is worth. Here is why that happens and a small script that finds these orders and gets them in front of a human, or recalculated, safely.
Shopware 6 treats orders as denormalized, workflow-optimized records. All line-item and price data is written once at order-place time, and amountTotal and amountNet are only ever recalculated when the Admin API's recalculate action explicitly runs the RecalculationService. Deleting an order line item, even the last one, is just a write to order-line-item, it never triggers that recalculation as a side effect. So an order can end up with zero line items and a stale nonzero total, which is the exact data-integrity bug tracked upstream as shopware/shopware#5148. Run a small Python or Node.js script that searches orders with their line items, flags any order where the line-item count is zero but the total is still above a small epsilon, and, only behind an explicit apply flag, calls POST /api/_action/order/{orderId}/recalculate to fix it. Full code, tests, and sources are below.
The problem in plain words
An order in Shopware 6 is not a live view over its line items. It is a snapshot. When the cart becomes an order, the totals, the taxes, and the per-item prices are all written once into the order's own columns, including amountTotal and amountNet on the order entity itself. After that point, nothing about the order automatically stays in sync with its line items. That design is deliberate, it keeps historic orders stable even if a product's price or tax rate changes later, and it keeps writes to individual line items fast because they do not have to walk back up and rebuild the whole order every time.
The trade-off is that any code path that changes the line items without also calling the recalculation step leaves the order's totals frozen at whatever they were before. Deleting an order line item, whether through DELETE /api/order-line-item/{id} or a bulk removal in the Administration, is exactly that kind of write. The delete itself succeeds, the association between the order and its line items becomes empty, and that is where the write path stops. Nothing in it calls the service that would rebuild the order's CartPrice. Delete the last line item and you get an order with no products in it and a total that still says it is worth what it used to be.
Why it happens
This is not a bug in the delete endpoint. It is a gap between how orders are modeled and what merchants assume happens automatically. A few things make it easy to hit:
- Removing every line item on an order is a normal, supported action, both through
DELETE /api/order-line-item/{id}and through bulk removal in the Administration, and Shopware does not warn that the order's total will now be stale. - The recalculation step is opt-in on purpose. Recalculating an order is a deliberate action, exposed as its own Admin API endpoint, precisely because Shopware does not want an order's historic totals to shift as a side effect of an unrelated write.
- The Administration UI itself has related gaps around recalculation when editing orders, for example promotions not being recalculated when an order is edited, tracked as shopware/shopware#3737, so this is one instance of a broader pattern rather than an isolated glitch.
- The stale total does not throw an error anywhere. The order looks completely normal in a list view, it is only when someone opens it, or a report sums up totals against line items, that the mismatch is visible.
The delete did exactly what it was told to do, remove the line item. The order's total was never that line item's job to maintain, it is the recalculation service's job, and nothing called it. So the fix is not to make deletes smarter. It is to detect orders where the line items and the total have drifted apart, and only then decide, carefully, whether to recalculate or to leave it for a human, because an order with zero line items is also an order that might be mid-cancellation or otherwise unusual.
The fix, as a flow
We do not touch stateId or any state machine transition here. The script searches order with its lineItems association, and for each order checks a small pure rule: if the order is cancelled, the stale total does not matter and we skip it. Otherwise, if it has zero line items but amountTotal or amountNet is still above a small epsilon, it gets flagged. Only when an explicit apply flag is set does the script attempt the recalculate action, and even then it catches failures and falls back to flagging for manual review instead of crash-looping.
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 recalculate action.
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) : {};
}
Search orders with their line items
A filter for lineItems.id equals null is unreliable for a to-many association, so instead we fetch every order with its lineItems association included and read the count in application code. We page through with page and limit, and read total-count-mode:1 so we know when to stop.
def search_orders(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"associations": {"lineItems": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
async function searchOrders(token, page = 1, limit = 500) {
const body = {
page,
limit,
associations: { lineItems: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
Decide, with one pure function
Keep the decision out of the network code entirely. Given a plain object with the line-item count, the two totals, and the order's state machine technical name, this function returns a flag decision and a reason. A cancelled order's stale total does not matter, so it is never flagged. Everything else is a simple threshold check against a small epsilon, since floating point totals are never exactly zero.
def classify_order_total_stale(order, epsilon=0.01):
if order.get("stateMachineTechnicalName") == "cancelled":
return {"flagged": False, "reason": "order cancelled, stale total is moot"}
line_item_count = order.get("lineItemCount", 0)
amount_total = order.get("amountTotal", 0.0)
amount_net = order.get("amountNet", 0.0)
if line_item_count == 0 and (amount_total > epsilon or amount_net > epsilon):
return {"flagged": True, "reason": "zero line items but nonzero total, needs recalculation"}
return {"flagged": False, "reason": "consistent"}
export function classifyOrderTotalStale(order, epsilon = 0.01) {
if (order.stateMachineTechnicalName === "cancelled") {
return { flagged: false, reason: "order cancelled, stale total is moot" };
}
const lineItemCount = order.lineItemCount ?? 0;
const amountTotal = order.amountTotal ?? 0;
const amountNet = order.amountNet ?? 0;
if (lineItemCount === 0 && (amountTotal > epsilon || amountNet > epsilon)) {
return { flagged: true, reason: "zero line items but nonzero total, needs recalculation" };
}
return { flagged: false, reason: "consistent" };
}
Cross-check against order-line-item directly
Before acting on a flagged order, confirm it is genuinely empty by searching order-line-item for that orderId and checking that zero rows come back. This guards against the to-many association being loaded incompletely rather than the order truly having no line items.
def confirm_genuinely_empty(token, order_id):
body = {"filter": [{"type": "equals", "field": "orderId", "value": order_id}]}
data = api("POST", "/api/search/order-line-item", token, body)
return len(data.get("data", [])) == 0
async function confirmGenuinelyEmpty(token, orderId) {
const body = { filter: [{ type: "equals", field: "orderId", value: orderId }] };
const data = await api("POST", "/api/search/order-line-item", token, body);
return (data.data || []).length === 0;
}
Recalculate only behind an explicit apply flag, or flag for review
amountTotal and amountNet are WriteProtected FloatFields, the Admin API rejects a direct PATCH to them. So when a flagged order is confirmed genuinely empty and DRY_RUN is off, call POST /api/_action/order/{orderId}/recalculate. This has been known to throw CHECKOUT__ORDER_RECALCULATION_FAILED on some Shopware versions for an order with zero line items, so wrap the call, and on failure fall back to logging the order for manual review instead of retrying in a loop.
Always start with DRY_RUN=true. Recalculating an order with zero line items is exactly the kind of write that can fail in ways the API was not designed for on every version, so treat every flagged order as report-first, and only attempt the recalculate call when you have reviewed the list and are ready to apply it.
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 ever recalculates an order that is confirmed to have zero line items and a stale total, and it falls back to flagging for manual review if the recalculate call fails.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 orders with a stale total after their last line item was deleted, and repair them, safely.
Shopware orders are denormalized, workflow-optimized records. amountTotal and amountNet are
only recalculated when the Admin API's recalculate action explicitly runs the
RecalculationService, never as a side effect of writing or deleting an order-line-item
entity. Deleting every line item on an order, including the last one, leaves the order with
zero line items and a stale nonzero total. This script finds those orders, confirms they are
genuinely empty, and, only behind an explicit apply flag, calls the recalculate action,
falling back to flagging for manual review if that call fails.
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("recalculate_stale_orders")
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("STALE_TOTAL_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 search_orders(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"associations": {"lineItems": {}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def confirm_genuinely_empty(token, order_id):
body = {"filter": [{"type": "equals", "field": "orderId", "value": order_id}]}
data = api("POST", "/api/search/order-line-item", token, body)
return len(data.get("data", [])) == 0
def classify_order_total_stale(order, epsilon=0.01):
if order.get("stateMachineTechnicalName") == "cancelled":
return {"flagged": False, "reason": "order cancelled, stale total is moot"}
line_item_count = order.get("lineItemCount", 0)
amount_total = order.get("amountTotal", 0.0)
amount_net = order.get("amountNet", 0.0)
if line_item_count == 0 and (amount_total > epsilon or amount_net > epsilon):
return {"flagged": True, "reason": "zero line items but nonzero total, needs recalculation"}
return {"flagged": False, "reason": "consistent"}
def recalculate_order(token, order_id):
api("POST", f"/api/_action/order/{order_id}/recalculate", token, {})
def to_classifier_input(order):
line_items = order.get("lineItems") or []
state = ((order.get("stateMachineState") or {}).get("technicalName"))
return {
"lineItemCount": len(line_items),
"amountTotal": order.get("amountTotal", 0.0),
"amountNet": order.get("amountNet", 0.0),
"stateMachineTechnicalName": state,
}
def run():
token = get_token()
flagged = 0
recalculated = 0
failed = 0
page = 1
while True:
data = search_orders(token, page=page)
rows = data.get("data", [])
if not rows:
break
for row in rows:
decision = classify_order_total_stale(to_classifier_input(row), EPSILON)
if not decision["flagged"]:
continue
order_id = row["id"]
order_number = row.get("orderNumber", "?")
if not confirm_genuinely_empty(token, order_id):
continue
flagged += 1
log.warning(
"FLAG order=%s id=%s amountTotal=%s amountNet=%s reason=%s",
order_number, order_id, row.get("amountTotal"), row.get("amountNet"), decision["reason"],
)
if DRY_RUN:
continue
try:
recalculate_order(token, order_id)
recalculated += 1
log.info("Recalculated order %s (%s).", order_number, order_id)
except Exception as exc:
failed += 1
log.error(
"Recalculate failed for order %s (%s): %s. Flagging for manual review instead.",
order_number, order_id, exc,
)
if page * 500 >= data.get("total", 0):
break
page += 1
log.info(
"Done. %d order(s) flagged, %d recalculated, %d failed and left for manual review.",
flagged, recalculated, failed,
)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 orders with a stale total after their last line item was deleted, and repair them, safely.
*
* Shopware orders are denormalized, workflow-optimized records. amountTotal and amountNet are
* only recalculated when the Admin API's recalculate action explicitly runs the
* RecalculationService, never as a side effect of writing or deleting an order-line-item
* entity. Deleting every line item on an order, including the last one, leaves the order with
* zero line items and a stale nonzero total. This script finds those orders, confirms they are
* genuinely empty, and, only behind an explicit apply flag, calls the recalculate action,
* falling back to flagging for manual review if that call fails.
* 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.STALE_TOTAL_EPSILON || 0.01);
export function classifyOrderTotalStale(order, epsilon = 0.01) {
if (order.stateMachineTechnicalName === "cancelled") {
return { flagged: false, reason: "order cancelled, stale total is moot" };
}
const lineItemCount = order.lineItemCount ?? 0;
const amountTotal = order.amountTotal ?? 0;
const amountNet = order.amountNet ?? 0;
if (lineItemCount === 0 && (amountTotal > epsilon || amountNet > epsilon)) {
return { flagged: true, reason: "zero line items but nonzero total, needs recalculation" };
}
return { flagged: false, reason: "consistent" };
}
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 searchOrders(token, page = 1, limit = 500) {
const body = {
page,
limit,
associations: { lineItems: {} },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
async function confirmGenuinelyEmpty(token, orderId) {
const body = { filter: [{ type: "equals", field: "orderId", value: orderId }] };
const data = await api("POST", "/api/search/order-line-item", token, body);
return (data.data || []).length === 0;
}
async function recalculateOrder(token, orderId) {
await api("POST", `/api/_action/order/${orderId}/recalculate`, token, {});
}
function toClassifierInput(order) {
const lineItems = order.lineItems || [];
const state = order.stateMachineState?.technicalName;
return {
lineItemCount: lineItems.length,
amountTotal: order.amountTotal ?? 0,
amountNet: order.amountNet ?? 0,
stateMachineTechnicalName: state,
};
}
export async function run() {
const token = await getToken();
let flagged = 0;
let recalculated = 0;
let failed = 0;
let page = 1;
while (true) {
const data = await searchOrders(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
for (const row of rows) {
const decision = classifyOrderTotalStale(toClassifierInput(row), EPSILON);
if (!decision.flagged) continue;
const orderId = row.id;
const orderNumber = row.orderNumber || "?";
if (!(await confirmGenuinelyEmpty(token, orderId))) continue;
flagged++;
console.warn(
`FLAG order=${orderNumber} id=${orderId} amountTotal=${row.amountTotal} amountNet=${row.amountNet} reason=${decision.reason}`
);
if (DRY_RUN) continue;
try {
await recalculateOrder(token, orderId);
recalculated++;
console.log(`Recalculated order ${orderNumber} (${orderId}).`);
} catch (err) {
failed++;
console.error(
`Recalculate failed for order ${orderNumber} (${orderId}): ${err.message}. Flagging for manual review instead.`
);
}
}
if (page * 500 >= (data.total || 0)) break;
page++;
}
console.log(
`Done. ${flagged} order(s) flagged, ${recalculated} recalculated, ${failed} failed and left 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 an order gets treated as broken and, eventually, gets its financial totals rewritten. Because classify_order_total_stale is pure, no network and no Shopware instance is needed. It just takes plain objects in and checks the answer.
from recalculate_stale_orders import classify_order_total_stale
def order(**over):
base = {"lineItemCount": 0, "amountTotal": 249.99, "amountNet": 210.08, "stateMachineTechnicalName": "open"}
base.update(over)
return base
def test_flagged_when_zero_items_and_nonzero_total():
result = classify_order_total_stale(order())
assert result == {"flagged": True, "reason": "zero line items but nonzero total, needs recalculation"}
def test_not_flagged_when_cancelled():
result = classify_order_total_stale(order(stateMachineTechnicalName="cancelled"))
assert result == {"flagged": False, "reason": "order cancelled, stale total is moot"}
def test_not_flagged_when_has_line_items():
result = classify_order_total_stale(order(lineItemCount=2))
assert result == {"flagged": False, "reason": "consistent"}
def test_not_flagged_when_total_already_zero():
result = classify_order_total_stale(order(amountTotal=0.0, amountNet=0.0))
assert result == {"flagged": False, "reason": "consistent"}
def test_not_flagged_when_total_within_epsilon():
result = classify_order_total_stale(order(amountTotal=0.005, amountNet=0.0), epsilon=0.01)
assert result == {"flagged": False, "reason": "consistent"}
def test_flagged_when_only_net_is_nonzero():
result = classify_order_total_stale(order(amountTotal=0.0, amountNet=50.0))
assert result == {"flagged": True, "reason": "zero line items but nonzero total, needs recalculation"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyOrderTotalStale } from "./recalculate-stale-orders.js";
const order = (over = {}) => ({
lineItemCount: 0,
amountTotal: 249.99,
amountNet: 210.08,
stateMachineTechnicalName: "open",
...over,
});
test("flagged when zero items and nonzero total", () => {
const result = classifyOrderTotalStale(order());
assert.deepEqual(result, { flagged: true, reason: "zero line items but nonzero total, needs recalculation" });
});
test("not flagged when cancelled", () => {
const result = classifyOrderTotalStale(order({ stateMachineTechnicalName: "cancelled" }));
assert.deepEqual(result, { flagged: false, reason: "order cancelled, stale total is moot" });
});
test("not flagged when has line items", () => {
const result = classifyOrderTotalStale(order({ lineItemCount: 2 }));
assert.deepEqual(result, { flagged: false, reason: "consistent" });
});
test("not flagged when total already zero", () => {
const result = classifyOrderTotalStale(order({ amountTotal: 0, amountNet: 0 }));
assert.deepEqual(result, { flagged: false, reason: "consistent" });
});
test("not flagged when total within epsilon", () => {
const result = classifyOrderTotalStale(order({ amountTotal: 0.005, amountNet: 0 }), 0.01);
assert.deepEqual(result, { flagged: false, reason: "consistent" });
});
test("flagged when only net is nonzero", () => {
const result = classifyOrderTotalStale(order({ amountTotal: 0, amountNet: 50 }));
assert.deepEqual(result, { flagged: true, reason: "zero line items but nonzero total, needs recalculation" });
});
Case studies
A support agent cleared an order to start over
A furniture store lets support agents remove and re-add items on an order when a customer changes their mind on a large purchase. One agent deleted every item to rebuild the order from a fresh quote, planning to add the new items right after. A phone call interrupted the process, and the order sat for two days with zero line items and a total still reading eleven hundred dollars.
Running the script in dry run caught it the next morning, well before month-end reporting, with the order number and the exact stale amount in the log. A recalculation, applied after review, brought the total to zero to match the empty cart, and the agent finished rebuilding the order from there.
A test order cleanup script emptied line items instead of cancelling
A developer wrote a one-off cleanup script to remove seed data from a staging-turned-production instance and accidentally pointed it at a batch of real customer orders, deleting their line items instead of the intended test rows. The orders remained open with full totals and no products, silently overstating revenue in the next sales report.
The scheduled job flagged all of them in one run, cross-checked each against order-line-item directly to confirm they were genuinely empty, and reported the full list for finance to review before anyone applied a fix, avoiding a rushed recalculation on orders that might have needed manual reconstruction instead.
After this runs on a schedule, an order that lost all its line items no longer sits silently overstating revenue. It shows up in a flagged report with its order number and stale amount, gets cross-checked against the line items table so nothing is flagged on a loading fluke, and only gets recalculated when you have explicitly said to apply the fix. If the recalculate call itself fails, the order is handed to a human instead of the script retrying blindly.
FAQ
Why does a Shopware 6 order keep a nonzero total after all its line items are deleted?
Shopware orders are denormalized, workflow-optimized records. All price data is written once at order-place time, and amountTotal and amountNet are only ever recalculated when the Admin API recalculate action is explicitly called. Deleting a line item, even the last one, never triggers that recalculation on its own, so the order ends up with zero line items but a stale nonzero total.
Can I fix the stale total by writing amountTotal directly?
No. amountTotal and amountNet are WriteProtected FloatFields on the order entity, so the Admin API rejects a direct PATCH to them. The only correct corrective action is POST to the order recalculate action, which rebuilds the order's CartPrice from its current line items.
Is it safe to automatically recalculate every order with zero line items?
Treat it as flag-first. Recalculating an order with zero line items has triggered CHECKOUT__ORDER_RECALCULATION_FAILED in some Shopware versions, so the safe pattern is to attempt the recalculate action only behind an explicit apply flag, catch failures, and fall back to flagging the order for manual review rather than crash-looping or leaving it silently wrong.
Related field notes
Citations
On the problem:
- GitHub Issue: Last line item of order can be deleted, leading to incorrect order total. github.com/shopware/shopware/issues/5148
- GitHub Issue: Promotions not recalculated when editing order in administration. github.com/shopware/shopware/issues/3737
- Shopware Community Forum: Recalculate via API. forum.shopware.com recalculate-via-api
On the solution:
- Shopware Admin API Reference: Order Management, including the recalculate action. shopware.stoplight.io admin-api order-management
- Shopware Developer Documentation: Orders, the checkout concept. developer.shopware.com checkout-concept orders
- Shopware platform source: RecalculationService.php. github.com/shopware/platform RecalculationService.php
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 catch a stale total?
If this saved you a wrong revenue report or a confused finance review, 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