Diagnostic Order State Machine

Orders can be force cancelled outside allowed transitions

A support script, an impatient admin user, or a retry loop calls the order cancel endpoint on an order that is already completed, or otherwise nowhere near a legal cancel edge. It should fail. For a long stretch of Shopware 6's life, it did not. A hardcoded escape hatch inside the state machine let the cancel action through no matter what, quietly corrupting the state graph. Here is why that happened, how Shopware fixed it, and a small script that audits your order history for every order this force cancel touched.

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

StateMachineRegistry::getTransitionDestinationById() historically contained a special case: "Always allow to cancel a payment whether its a valid transition or not." It matched any transition whose action name was cancel and returned the target state without checking whether that edge actually existed for the current state, and it ran for every state machine, not just the payment one it was written for. So POST /api/_action/order/{orderId}/state/cancel on an order sitting in completed, which has no completed to cancelled edge, still succeeded and force-set stateMachineState to cancelled. Shopware deleted that branch in PR #3732, closing issue #3730 (tracked internally as NEXT-23423), so cancel is now validated exactly like any other transition. A small Python or Node.js script can rebuild the legal edge graph, walk state-machine-history, and flag every order that was force cancelled outside a real transition. Full code, tests, and sources are below.

The problem in plain words

Every order in Shopware 6 moves through its order.state state machine along named edges: open to in_progress by process, in_progress to completed by complete, and so on. The whole point of a state machine is that a state can only move along an edge that is actually defined, so StateMachineRegistry is supposed to check the current state's outgoing transitions before letting anything move.

Except for one action name. Deep in getTransitionDestinationById(), a comment reading "Always allow to cancel a payment whether its a valid transition or not" guarded a branch that matched on actionName === 'cancel' and returned the destination state without ever consulting the graph. That check was meant for the payment state machine, where forcing a cancel through made some operational sense, but it was written generically, so it fired for order.state too. Call the cancel transition on an order that is completed, a state with no outgoing cancel edge in the default flow, and it still worked. The order flipped straight to cancelled, no error, no warning, and the state graph was now saying something that could never have happened through a legal path.

POST .../state/cancel order is completed no legal cancel edge getTransitionDestinationById actionName === 'cancel' ? skip the graph check no validation Succeeds anyway 200 OK, no error stateMachineState forced to cancelled
The cancel action bypassed the state machine graph entirely, so an order that had no legal path to cancelled still landed there, corrupting the state history.

Why it happens

The root cause sits in one method, but it surfaces in ordinary operational moments. A few common ways teams ran into it:

This was a real defect in the platform, not a misunderstanding of the docs. It is tracked publicly as GitHub issue #3730, "every order can be canceled, even if not allowed via statemachine transition," and Shopware closed it by removing the special case entirely in PR #3732. See the citations at the end for the exact issue and fix.

The key insight

The fix is not to special-case cancel at all, in either direction. Shopware's corrected StateMachineRegistry validates cancel exactly like every other transition: the edge from the current state to the target state, under that specific action name, must exist in the state machine's configured state_machine_transition rows. A detection script should mirror that same rule. Build the real edge graph once, then check every historical cancel against it rather than trusting that toStateMachineState.technicalName === 'cancelled' ever implies the move was legal.

The fix, as a flow

We never try to auto-reverse anything, since there is no guaranteed inverse transition and money, stock, or documents may already have reacted to the bad cancel. Instead the script pulls the real order.state transition graph, walks state-machine-history for every recorded order transition, and flags any row where the action was cancel but that exact fromState to cancelled edge does not exist in the graph. Each flagged row becomes an audit record for manual review.

Fetch legal edges state-machine, order.state Walk transition history state-machine-history entityName: order isLegalTransition pure decision function no special-case for cancel Edge exists for cancel? no, illegal cancel Audit record flagged for review yes, legal left alone
The pure check applies the same rule Shopware's corrected state machine applies: cancel must have a real edge from the order's actual prior state, or the history row gets flagged instead of trusted.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, this script only ever reports
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, this script only ever reports
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for the state machine graph search, the order search, and the history search.

step2.py
import os, requests

SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]

def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
step2.js
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Pull the real order.state transition graph

Search state-machine filtered to technicalName: order.state, with the transitions association nested with fromStateMachineState and toStateMachineState. Fold the response into a map of fromState.technicalName -> {actionName: toState.technicalName}, the same shape the corrected StateMachineRegistry checks against.

step3.py
def fetch_order_state_edges(token):
    body = {
        "filter": [{"type": "equals", "field": "technicalName", "value": "order.state"}],
        "associations": {
            "transitions": {"associations": {"fromStateMachineState": {}, "toStateMachineState": {}}}
        },
        "limit": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()["data"]
    machine = data[0] if data else {}
    transitions = ((machine.get("transitions") or {}).get("data")) or machine.get("transitions") or []
    if isinstance(transitions, dict):
        transitions = transitions.get("data", [])

    edges = {}
    for t in transitions:
        from_name = (t.get("fromStateMachineState") or {}).get("technicalName")
        to_name = (t.get("toStateMachineState") or {}).get("technicalName")
        action = t.get("actionName")
        if not (from_name and to_name and action):
            continue
        edges.setdefault(from_name, {})[action] = to_name
    return edges
step3.js
async function fetchOrderStateEdges(token) {
  const body = {
    filter: [{ type: "equals", field: "technicalName", value: "order.state" }],
    associations: {
      transitions: { associations: { fromStateMachineState: {}, toStateMachineState: {} } },
    },
    limit: 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine search`);
  const payload = await res.json();
  const machine = payload.data?.[0] || {};
  const raw = machine.transitions;
  const transitions = Array.isArray(raw) ? raw : raw?.data || [];

  const edges = {};
  for (const t of transitions) {
    const fromName = t.fromStateMachineState?.technicalName;
    const toName = t.toStateMachineState?.technicalName;
    const action = t.actionName;
    if (!fromName || !toName || !action) continue;
    edges[fromName] = edges[fromName] || {};
    edges[fromName][action] = toName;
  }
  return edges;
}
4

Decide, with one pure function

The rule mirrors the corrected StateMachineRegistry exactly: no special case for actionName === 'cancel'. Look up the edges for the from state, get the candidate target for that action name, and require it to equal the actual target. If the from state is not in the map, or the action does not lead to that target, the transition was not legal.

decide.py
def is_legal_transition(from_state, to_state, action_name, allowed_edges):
    edges = allowed_edges.get(from_state)
    if edges is None:
        return False
    candidate = edges.get(action_name)
    return candidate is not None and candidate == to_state
decide.js
export function isLegalTransition(fromState, toState, actionName, allowedEdges) {
  const edges = allowedEdges[fromState];
  if (edges === undefined) return false;
  const candidate = edges[actionName];
  return candidate !== undefined && candidate === toState;
}
5

Walk state-machine-history and flag illegal cancels

Search state-machine-history filtered to entityName: order, sorted by createdAt, reading entityId, fromStateMachineState.technicalName, toStateMachineState.technicalName, transitionActionName, and createdAt. For each row where the action was cancel, run it through isLegalTransition. A false result means that order was force cancelled outside a real edge.

apply.py
def fetch_order_cancel_history(token, limit=100):
    body = {
        "filter": [
            {"type": "equals", "field": "entityName", "value": "order"},
            {"type": "equals", "field": "transitionActionName", "value": "cancel"},
        ],
        "associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "limit": limit,
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-history",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def build_audit_record(history_row):
    from_name = (history_row.get("fromStateMachineState") or {}).get("technicalName")
    to_name = (history_row.get("toStateMachineState") or {}).get("technicalName")
    return {
        "historyId": history_row.get("id"),
        "orderId": history_row.get("entityId"),
        "fromState": from_name,
        "toState": to_name,
        "transitionActionName": history_row.get("transitionActionName"),
        "occurredAt": history_row.get("createdAt"),
    }
apply.js
async function fetchOrderCancelHistory(token, limit = 100) {
  const body = {
    filter: [
      { type: "equals", field: "entityName", value: "order" },
      { type: "equals", field: "transitionActionName", value: "cancel" },
    ],
    associations: { fromStateMachineState: {}, toStateMachineState: {} },
    sort: [{ field: "createdAt", order: "ASC" }],
    limit,
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-history search`);
  const data = await res.json();
  return data.data;
}

function buildAuditRecord(historyRow) {
  return {
    historyId: historyRow.id,
    orderId: historyRow.entityId,
    fromState: historyRow.fromStateMachineState?.technicalName,
    toState: historyRow.toStateMachineState?.technicalName,
    transitionActionName: historyRow.transitionActionName,
    occurredAt: historyRow.createdAt,
  };
}
6

Wire it together as a report-only run

This job never writes anything. It reads the legal edges once, walks every cancel row in history, and prints an audit record for each one that isLegalTransition rejects. DRY_RUN stays on by convention since there is nothing safe to auto-repair, but if you extend this into a preventive guard in front of your own cancel calls, gate the actual transition call behind the same check and return a 409-style report instead of calling the endpoint.

Run it safe

Do not try to reverse a force cancelled order automatically. There is no guaranteed inverse transition, and stock, documents, or payment records may already have reacted. Treat every flagged row as a manual review and finance reconciliation task, and if you build a preventive guard for new cancels, make it reject before ever calling /api/_action/order/{orderId}/state/cancel.

The full code

Here is the complete script in one file for each language. It authenticates, fetches the real order.state edge graph, walks state-machine-history for cancel actions on orders, and prints an audit record for every one that did not have a legal edge, entirely read-only.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

audit_force_cancels.py
"""Audit Shopware 6 orders that were force cancelled outside a legal
state machine transition.

StateMachineRegistry::getTransitionDestinationById() historically special-cased
actionName === 'cancel' and let it through regardless of whether the edge existed
in the state machine graph (fixed in platform PR #3732, closing issue #3730).
This script rebuilds the real order.state edge graph, walks state-machine-history
for every order cancel, and reports the ones that had no legal edge. Read-only,
report only. 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("audit_force_cancels")

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"
HISTORY_LIMIT = int(os.environ.get("HISTORY_LIMIT", "100"))


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def fetch_order_state_edges(token):
    body = {
        "filter": [{"type": "equals", "field": "technicalName", "value": "order.state"}],
        "associations": {
            "transitions": {"associations": {"fromStateMachineState": {}, "toStateMachineState": {}}}
        },
        "limit": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    data = r.json()["data"]
    machine = data[0] if data else {}
    transitions = ((machine.get("transitions") or {}).get("data")) or machine.get("transitions") or []
    if isinstance(transitions, dict):
        transitions = transitions.get("data", [])

    edges = {}
    for t in transitions:
        from_name = (t.get("fromStateMachineState") or {}).get("technicalName")
        to_name = (t.get("toStateMachineState") or {}).get("technicalName")
        action = t.get("actionName")
        if not (from_name and to_name and action):
            continue
        edges.setdefault(from_name, {})[action] = to_name
    return edges


def is_legal_transition(from_state, to_state, action_name, allowed_edges):
    edges = allowed_edges.get(from_state)
    if edges is None:
        return False
    candidate = edges.get(action_name)
    return candidate is not None and candidate == to_state


def fetch_order_cancel_history(token, limit=100):
    body = {
        "filter": [
            {"type": "equals", "field": "entityName", "value": "order"},
            {"type": "equals", "field": "transitionActionName", "value": "cancel"},
        ],
        "associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "limit": limit,
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-history",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def build_audit_record(history_row):
    from_name = (history_row.get("fromStateMachineState") or {}).get("technicalName")
    to_name = (history_row.get("toStateMachineState") or {}).get("technicalName")
    return {
        "historyId": history_row.get("id"),
        "orderId": history_row.get("entityId"),
        "fromState": from_name,
        "toState": to_name,
        "transitionActionName": history_row.get("transitionActionName"),
        "occurredAt": history_row.get("createdAt"),
    }


def run():
    token = get_token()
    allowed_edges = fetch_order_state_edges(token)
    history = fetch_order_cancel_history(token, HISTORY_LIMIT)

    flagged = 0
    for row in history:
        from_name = (row.get("fromStateMachineState") or {}).get("technicalName")
        to_name = (row.get("toStateMachineState") or {}).get("technicalName")
        action = row.get("transitionActionName")
        if is_legal_transition(from_name, to_name, action, allowed_edges):
            continue
        record = build_audit_record(row)
        log.warning("Illegal force cancel: order %s went %s -> %s via '%s' at %s",
                    record["orderId"], record["fromState"], record["toState"],
                    record["transitionActionName"], record["occurredAt"])
        flagged += 1

    log.info("Done. %d order(s) flagged as force cancelled outside a legal transition.", flagged)


if __name__ == "__main__":
    run()
audit-force-cancels.js
/**
 * Audit Shopware 6 orders that were force cancelled outside a legal
 * state machine transition.
 *
 * StateMachineRegistry::getTransitionDestinationById() historically special-cased
 * actionName === 'cancel' and let it through regardless of whether the edge existed
 * in the state machine graph (fixed in platform PR #3732, closing issue #3730).
 * This script rebuilds the real order.state edge graph, walks state-machine-history
 * for every order cancel, and reports the ones that had no legal edge. Read-only,
 * report only. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/order-force-cancel-illegal-transition/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HISTORY_LIMIT = Number(process.env.HISTORY_LIMIT || 100);

export function isLegalTransition(fromState, toState, actionName, allowedEdges) {
  const edges = allowedEdges[fromState];
  if (edges === undefined) return false;
  const candidate = edges[actionName];
  return candidate !== undefined && candidate === toState;
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function fetchOrderStateEdges(token) {
  const body = {
    filter: [{ type: "equals", field: "technicalName", value: "order.state" }],
    associations: {
      transitions: { associations: { fromStateMachineState: {}, toStateMachineState: {} } },
    },
    limit: 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine search`);
  const payload = await res.json();
  const machine = payload.data?.[0] || {};
  const raw = machine.transitions;
  const transitions = Array.isArray(raw) ? raw : raw?.data || [];

  const edges = {};
  for (const t of transitions) {
    const fromName = t.fromStateMachineState?.technicalName;
    const toName = t.toStateMachineState?.technicalName;
    const action = t.actionName;
    if (!fromName || !toName || !action) continue;
    edges[fromName] = edges[fromName] || {};
    edges[fromName][action] = toName;
  }
  return edges;
}

async function fetchOrderCancelHistory(token, limit = 100) {
  const body = {
    filter: [
      { type: "equals", field: "entityName", value: "order" },
      { type: "equals", field: "transitionActionName", value: "cancel" },
    ],
    associations: { fromStateMachineState: {}, toStateMachineState: {} },
    sort: [{ field: "createdAt", order: "ASC" }],
    limit,
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-history search`);
  const data = await res.json();
  return data.data;
}

export function buildAuditRecord(historyRow) {
  return {
    historyId: historyRow.id,
    orderId: historyRow.entityId,
    fromState: historyRow.fromStateMachineState?.technicalName,
    toState: historyRow.toStateMachineState?.technicalName,
    transitionActionName: historyRow.transitionActionName,
    occurredAt: historyRow.createdAt,
  };
}

export async function run() {
  const token = await getToken();
  const allowedEdges = await fetchOrderStateEdges(token);
  const history = await fetchOrderCancelHistory(token, HISTORY_LIMIT);

  let flagged = 0;
  for (const row of history) {
    const fromName = row.fromStateMachineState?.technicalName;
    const toName = row.toStateMachineState?.technicalName;
    const action = row.transitionActionName;
    if (isLegalTransition(fromName, toName, action, allowedEdges)) continue;
    const record = buildAuditRecord(row);
    console.warn(`Illegal force cancel: order ${record.orderId} went ${record.fromState} -> ${record.toState} via '${record.transitionActionName}' at ${record.occurredAt}`);
    flagged++;
  }

  console.log(`Done. ${flagged} order(s) flagged as force cancelled outside a legal transition.`);
}

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 historical cancel gets flagged as illegal. Because isLegalTransition is pure and takes the edge graph as a plain argument, the test needs no network and no Shopware store. It just feeds in fixture graphs and checks the answer.

test_illegal_cancel.py
from audit_force_cancels import is_legal_transition

ORDER_STATE_EDGES = {
    "open": {"process": "in_progress", "cancel": "cancelled"},
    "in_progress": {"complete": "completed", "cancel": "cancelled"},
    "cancelled": {"reopen": "open"},
    "completed": {},
}


def test_legal_cancel_from_open():
    assert is_legal_transition("open", "cancelled", "cancel", ORDER_STATE_EDGES) is True


def test_legal_cancel_from_in_progress():
    assert is_legal_transition("in_progress", "cancelled", "cancel", ORDER_STATE_EDGES) is True


def test_illegal_cancel_from_completed_has_no_edge():
    assert is_legal_transition("completed", "cancelled", "cancel", ORDER_STATE_EDGES) is False


def test_illegal_when_from_state_unknown_to_graph():
    assert is_legal_transition("made_up_state", "cancelled", "cancel", ORDER_STATE_EDGES) is False


def test_illegal_when_action_name_does_not_match_edge():
    assert is_legal_transition("in_progress", "cancelled", "complete", ORDER_STATE_EDGES) is False


def test_illegal_when_candidate_target_differs_from_actual_target():
    assert is_legal_transition("open", "completed", "cancel", ORDER_STATE_EDGES) is False
illegal-cancel.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isLegalTransition } from "./audit-force-cancels.js";

const ORDER_STATE_EDGES = {
  open: { process: "in_progress", cancel: "cancelled" },
  in_progress: { complete: "completed", cancel: "cancelled" },
  cancelled: { reopen: "open" },
  completed: {},
};

test("legal cancel from open", () => {
  assert.equal(isLegalTransition("open", "cancelled", "cancel", ORDER_STATE_EDGES), true);
});

test("legal cancel from in_progress", () => {
  assert.equal(isLegalTransition("in_progress", "cancelled", "cancel", ORDER_STATE_EDGES), true);
});

test("illegal cancel from completed has no edge", () => {
  assert.equal(isLegalTransition("completed", "cancelled", "cancel", ORDER_STATE_EDGES), false);
});

test("illegal when from state unknown to graph", () => {
  assert.equal(isLegalTransition("made_up_state", "cancelled", "cancel", ORDER_STATE_EDGES), false);
});

test("illegal when action name does not match edge", () => {
  assert.equal(isLegalTransition("in_progress", "cancelled", "complete", ORDER_STATE_EDGES), false);
});

test("illegal when candidate target differs from actual target", () => {
  assert.equal(isLegalTransition("open", "completed", "cancel", ORDER_STATE_EDGES), false);
});

Case studies

Support tooling

The internal helper that cancelled completed orders

A furniture retailer built a small internal tool so support agents could cancel problem orders in one click. Nobody had checked whether cancel was actually legal from every state, since Shopware never complained. Months later a finance audit found dozens of completed orders sitting as cancelled, with shipped goods and settled payments that no longer matched the order's own status.

Running the history audit against the real order.state graph identified every affected order in minutes by matching each one to a cancel row with no legal edge. Finance reconciled each one by hand instead of guessing, and the support tool was rewritten to check isLegalTransition before ever calling cancel.

Retry loop

An integration that cancelled the same order twice

A marketplace sync integration retried a cancel call after a timeout, not realizing the first call had already succeeded. The second call landed on an order already cancelled, which should have been rejected outright, but the hardcoded escape hatch let it through as a no-op that still wrote a fresh history row, muddying the audit trail.

Once the platform fix landed, the same retry produced a proper validation failure instead of a phantom second cancel, and the team added the history audit as a weekly job to catch anything from before the upgrade.

What good looks like

After you run this audit, you have a precise list of every order that was force cancelled outside a legal transition, with the exact from state, to state, action name, and timestamp for each one, ready for finance or operations to reconcile by hand. Once you are on a Shopware version with PR #3732, new illegal cancels cannot happen from the platform itself. If you also gate your own cancel calls behind isLegalTransition, nothing in your stack can reintroduce the same corruption going forward.

FAQ

Why could a Shopware 6 order be cancelled even when cancel was not a valid transition?

StateMachineRegistry::getTransitionDestinationById() contained a hardcoded rule to always allow a cancel action, regardless of whether that transition actually existed as an edge from the order's current state in the state machine graph. Calling the cancel transition endpoint on an order in a state like completed, which has no completed to cancelled edge in the default order.state flow, still succeeded and force-set the order to cancelled.

Has Shopware fixed the cancel escape hatch in the state machine?

Yes. Shopware removed the special-cased branch in the platform repository in PR #3732, which closed issue #3730 and was tracked internally as NEXT-23423. After the fix, every transition, including cancel, is validated strictly against the configured state_machine_transition edges for that state machine, so an illegal cancel is rejected instead of silently applied.

Can I safely reverse an order that was force cancelled outside a legal transition?

Not automatically. There is no guaranteed inverse transition back to the order's original state, and stock, documents, or payment records may already have reacted to the cancellation. The safe approach is to detect and report every illegal cancel from state_machine_history for manual review, and to prevent new ones going forward by checking the transition against the legal edge graph before ever calling the cancel endpoint.

Related field notes

Citations

On the problem:

  1. every order can be canceled, even if not allowed via statemachine transition. GitHub Issue #3730, shopware/shopware. github.com/shopware/shopware/issues/3730
  2. fix state-machine transition handling. GitHub PR #3732, shopware/shopware. github.com/shopware/shopware/pull/3732
  3. Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html

On the solution:

  1. Shopware Admin API Reference (Stoplight): Order Management. shopware.stoplight.io/docs/admin-api/fdd24cc76f22d-order-management
  2. Shopware Admin API Reference (Stoplight): StateMachine. shopware.stoplight.io/docs/admin-api/4f023ac2306d8-state-machine
  3. Shopware Admin API Reference (Stoplight): StateMachineHistory. shopware.stoplight.io/docs/admin-api/9a2d77ad40c1e-state-machine-history

Stuck on a tricky one?

If you have a problem in Shopware orders, the state machine, stock, or the message queue that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a bad cancel?

If this helped you find orders that were force cancelled outside a legal transition, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopware field notes