Diagnostic Order State Machine
Order transaction missing a defined paid transition
A refund tool, a manual reconciliation script, or a support agent tries to move an order_transaction from paid_partially or reminded to paid, the same way it works for a plain open transaction. Shopware throws instead, as if the paid state did not exist at all. It exists, the row just is not there for that particular starting state under the action name your code is calling. Here is why some order_transaction state machines are missing that link and a small script that finds the gap and reports it safely.
In Shopware 6's order_transaction state machine, not every from-state that logically should reach paid has a transition row using the action name paid. Some paths, notably paid_partially to paid and reminded to paid, exist in the database only under the action name pay, and the core PHP class StateMachineTransitionActions defines ACTION_PAID but has no ACTION_PAY constant. Shops that customized their state machine, migrated from an older Shopware version, or rely on the documented constant instead of the raw action string can end up with the paid transition missing entirely for certain states, so the paid action throws with no matching transition row for that fromState. A small Python or Node.js script can list every transition row, diff it against the states that should reach paid, and report what is missing, gated by DRY_RUN before it ever writes a repair row. Full code, tests, and sources are below.
The problem in plain words
Every order_transaction in Shopware 6 moves through a state machine, a graph of named states connected by named transitions. Reaching paid from open works everywhere, because that is the path most tests and most documentation cover. But paid is also supposed to be reachable from paid_partially, once the rest of the balance clears, and from reminded, once a customer who was sent a payment reminder finally pays.
Those two paths are the ones that go missing. On some installs the database row for paid_partially to paid, or reminded to paid, exists under the action name pay instead of paid. On others, especially shops that customized their state machine or migrated from an older Shopware version, the row is not there under either name. Calling the paid action then throws, because Shopware's state machine registry looks for a transition row matching the exact (fromState, actionName) pair you asked for and finds nothing, even though the target state paid is perfectly real and reachable from other starting states.
Why it happens
The paid state itself is never missing. What is missing, on some installs, is one specific edge into it from one specific starting state, under the action name the caller expects. A few common ways shops end up here:
- The shop customized its order_transaction state machine through a plugin or a migration, and the customization only added a transition named pay from paid_partially or reminded, never one named paid.
- The shop migrated from an older Shopware version whose default state machine seed data used different action names, and the newer paid naming was never backfilled onto every path.
- A developer wrote code against the documented constant
StateMachineTransitionActions::ACTION_PAID, which is real and exists in core, but the shop's actual database rows for that path use the raw string pay instead, since there is noACTION_PAYconstant to catch the mismatch at compile time. - Different Shopware versions have renamed and reshuffled these payment-related transitions over time, so what worked on one install's paid_partially to paid path does not carry over cleanly to another.
This is a known gap in Shopware's own tracker, not a one-off misconfiguration. See the citations at the end for the exact issue threads and the developer documentation on the state machine.
Do not guess whether your shop's paid_partially to paid path is named pay or paid, and do not assume it exists at all. Read the actual state_machine_transition rows for the order_transaction state machine, and check them against the states that should reach paid. Because inserting a row changes core workflow behavior, and issue 5795 shows Shopware itself has disagreed internally about the right action name, the safe default is to report the gap for a human to confirm, not to silently patch it in.
The fix, as a flow
We never write anything by default. The script pulls the order_transaction state machine's id, every state's technicalName, and every transition row with its fromState, toState, and actionName. A pure function compares that transition list against the states that Shopware's core defaults say should reach paid, open, failed, paid_partially, and reminded, accepting either pay or paid as the action name, and returns the pairs that are missing. Only with an explicit opt-in and DRY_RUN off does it insert the missing rows.
Build it step by step
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.
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, change to false only after an explicit opt-in
// 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, change to false only after an explicit opt-in
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 every search call and, only if the operator opts in, for the transition insert.
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 {}
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) : {};
}
Find the order_transaction state machine and list its states and transitions
First search state-machine for technicalName equal to order_transaction.state to get its id. Then search state-machine-state filtered on that id to build a technicalName to id map for every state, including open, paid, paid_partially, failed, reminded, and refunded. Finally search state-machine-transition filtered on that id, with the fromStateMachineState and toStateMachineState associations, to pull every transition row with its actionName.
def get_state_machine_id(token):
body = {"filter": [{"type": "equals", "field": "technicalName", "value": "order_transaction.state"}]}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine", headers=_headers(token), json=body, timeout=30,
)
r.raise_for_status()
return r.json()["data"][0]["id"]
def get_states_by_technical_name(token, state_machine_id):
body = {"filter": [{"type": "equals", "field": "stateMachineId", "value": state_machine_id}], "limit": 100}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-state", headers=_headers(token), json=body, timeout=30,
)
r.raise_for_status()
return {row["technicalName"]: row["id"] for row in r.json()["data"]}
def get_transitions(token, state_machine_id):
body = {
"filter": [{"type": "equals", "field": "stateMachineId", "value": state_machine_id}],
"associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
"page": 1,
"limit": 500,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/state-machine-transition", headers=_headers(token), json=body, timeout=30,
)
r.raise_for_status()
rows = []
for row in r.json()["data"]:
rows.append({
"id": row["id"],
"fromStateTechnicalName": row["fromStateMachineState"]["technicalName"],
"toStateTechnicalName": row["toStateMachineState"]["technicalName"],
"actionName": row["actionName"],
})
return rows
async function getStateMachineId(token) {
const body = { filter: [{ type: "equals", field: "technicalName", value: "order_transaction.state" }] };
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine search`);
const data = await res.json();
return data.data[0].id;
}
async function getStatesByTechnicalName(token, stateMachineId) {
const body = { filter: [{ type: "equals", field: "stateMachineId", value: stateMachineId }], limit: 100 };
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-state`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-state search`);
const data = await res.json();
const map = {};
for (const row of data.data) map[row.technicalName] = row.id;
return map;
}
async function getTransitions(token, stateMachineId) {
const body = {
filter: [{ type: "equals", field: "stateMachineId", value: stateMachineId }],
associations: { fromStateMachineState: {}, toStateMachineState: {} },
page: 1,
limit: 500,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-transition search`);
const data = await res.json();
return data.data.map((row) => ({
id: row.id,
fromStateTechnicalName: row.fromStateMachineState.technicalName,
toStateTechnicalName: row.toStateMachineState.technicalName,
actionName: row.actionName,
}));
}
Decide, with one pure function
Keep the decision separate from any network call. Given the transition rows, a technicalName to id map, and the list of states that Shopware's core defaults say should reach paid, work out which ones are not covered by any row whose actionName is pay or paid. This is the exact shape of GitHub issue 5795: paid_partially and reminded are the two states most often found missing.
DEFAULT_REQUIRED_FROM_STATES = ["open", "failed", "paid_partially", "reminded"]
def plan_paid_transition_repair(transitions, state_ids_by_technical_name, required_from_states=None):
required_from_states = required_from_states or DEFAULT_REQUIRED_FROM_STATES
planned = []
for name in required_from_states:
covered = any(
t["fromStateTechnicalName"] == name
and t["toStateTechnicalName"] == "paid"
and t["actionName"] in ("pay", "paid")
for t in transitions
)
if not covered:
planned.append({
"fromStateId": state_ids_by_technical_name[name],
"toStateId": state_ids_by_technical_name["paid"],
"actionName": "paid",
"reason": f"missing {name}->paid transition",
})
return planned
export const DEFAULT_REQUIRED_FROM_STATES = ["open", "failed", "paid_partially", "reminded"];
export function planPaidTransitionRepair(transitions, stateIdsByTechnicalName, requiredFromStates = DEFAULT_REQUIRED_FROM_STATES) {
const planned = [];
for (const name of requiredFromStates) {
const covered = transitions.some(
(t) => t.fromStateTechnicalName === name && t.toStateTechnicalName === "paid" && ["pay", "paid"].includes(t.actionName)
);
if (!covered) {
planned.push({
fromStateId: stateIdsByTechnicalName[name],
toStateId: stateIdsByTechnicalName["paid"],
actionName: "paid",
reason: `missing ${name}->paid transition`,
});
}
}
return planned;
}
Report by default, insert only with an explicit opt-in
Log every planned pair so a human can confirm it against the shop's own customizations. Only when the operator sets an explicit opt-in flag and DRY_RUN is false does the script call POST /api/state-machine-transition with stateMachineId, actionName: "paid", fromStateId, and toStateId for each planned row. Never PATCH stateId directly on the order_transaction itself; after the row exists, retry the original workflow through the transition action endpoint.
def insert_transition(token, state_machine_id, planned_row):
body = {
"stateMachineId": state_machine_id,
"actionName": planned_row["actionName"],
"fromStateId": planned_row["fromStateId"],
"toStateId": planned_row["toStateId"],
}
return api_post("/api/state-machine-transition", token, body=body)
async function insertTransition(token, stateMachineId, plannedRow) {
const body = {
stateMachineId,
actionName: plannedRow.actionName,
fromStateId: plannedRow.fromStateId,
toStateId: plannedRow.toStateId,
};
return apiPost("/api/state-machine-transition", token, body);
}
Wire it together with a dry run guard
The loop ties every piece together. It always lists and plans. It only inserts when both DRY_RUN is false and a separate ALLOW_REPAIR opt-in is set, so a single flag flip can never accidentally write to the state machine. Because planPaidTransitionRepair only ever plans pairs that are truly missing, running it again after a repair returns an empty list, so it is always safe to re-run.
Start with DRY_RUN=true and leave ALLOW_REPAIR unset. Read the reported gaps, confirm the required from-states match your shop's own customizations, and only then opt in to a write. Inserting a transition row changes core workflow behavior, so this is a decision for a human, not a default.
The full code
Here is the complete script in one file for each language. It authenticates, resolves the order_transaction state machine, lists every state and transition row, plans any missing paid-transition pairs with a pure function, and only inserts a repair row when both DRY_RUN is false and ALLOW_REPAIR is explicitly set.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Detect and report missing paid transitions on the Shopware 6 order_transaction
state machine (shopware/shopware issue #5795).
Some installs are missing a transition row from paid_partially or reminded to paid,
because those paths only exist under the action name "pay" and the core
StateMachineTransitionActions class has no ACTION_PAY constant. This lists every
transition row, plans any missing (fromState -> paid) pairs with a pure function,
and reports them. It only inserts a repair row when both DRY_RUN is false and the
operator explicitly sets ALLOW_REPAIR=true. 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("check_paid_transition")
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"
ALLOW_REPAIR = os.environ.get("ALLOW_REPAIR", "false").lower() == "true"
DEFAULT_REQUIRED_FROM_STATES = ["open", "failed", "paid_partially", "reminded"]
def _headers(token):
return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"}
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=_headers(token), json=body or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def get_state_machine_id(token):
body = {"filter": [{"type": "equals", "field": "technicalName", "value": "order_transaction.state"}]}
r = requests.post(f"{SHOPWARE_URL}/api/search/state-machine", headers=_headers(token), json=body, timeout=30)
r.raise_for_status()
return r.json()["data"][0]["id"]
def get_states_by_technical_name(token, state_machine_id):
body = {"filter": [{"type": "equals", "field": "stateMachineId", "value": state_machine_id}], "limit": 100}
r = requests.post(f"{SHOPWARE_URL}/api/search/state-machine-state", headers=_headers(token), json=body, timeout=30)
r.raise_for_status()
return {row["technicalName"]: row["id"] for row in r.json()["data"]}
def get_transitions(token, state_machine_id):
body = {
"filter": [{"type": "equals", "field": "stateMachineId", "value": state_machine_id}],
"associations": {"fromStateMachineState": {}, "toStateMachineState": {}},
"page": 1,
"limit": 500,
}
r = requests.post(f"{SHOPWARE_URL}/api/search/state-machine-transition", headers=_headers(token), json=body, timeout=30)
r.raise_for_status()
rows = []
for row in r.json()["data"]:
rows.append({
"id": row["id"],
"fromStateTechnicalName": row["fromStateMachineState"]["technicalName"],
"toStateTechnicalName": row["toStateMachineState"]["technicalName"],
"actionName": row["actionName"],
})
return rows
def plan_paid_transition_repair(transitions, state_ids_by_technical_name, required_from_states=None):
required_from_states = required_from_states or DEFAULT_REQUIRED_FROM_STATES
planned = []
for name in required_from_states:
covered = any(
t["fromStateTechnicalName"] == name
and t["toStateTechnicalName"] == "paid"
and t["actionName"] in ("pay", "paid")
for t in transitions
)
if not covered:
planned.append({
"fromStateId": state_ids_by_technical_name[name],
"toStateId": state_ids_by_technical_name["paid"],
"actionName": "paid",
"reason": f"missing {name}->paid transition",
})
return planned
def insert_transition(token, state_machine_id, planned_row):
body = {
"stateMachineId": state_machine_id,
"actionName": planned_row["actionName"],
"fromStateId": planned_row["fromStateId"],
"toStateId": planned_row["toStateId"],
}
return api_post("/api/state-machine-transition", token, body=body)
def run():
token = get_token()
state_machine_id = get_state_machine_id(token)
state_ids = get_states_by_technical_name(token, state_machine_id)
transitions = get_transitions(token, state_machine_id)
planned = plan_paid_transition_repair(transitions, state_ids)
if not planned:
log.info("No missing paid transitions found. Every required state can reach paid.")
return
for row in planned:
log.warning("Missing paid transition: %s", row["reason"])
if not ALLOW_REPAIR:
log.info("%d missing pair(s) reported. Set ALLOW_REPAIR=true and DRY_RUN=false to insert them.", len(planned))
return
if DRY_RUN:
for row in planned:
log.info("DRY_RUN: would insert %s", row)
return
for row in planned:
insert_transition(token, state_machine_id, row)
log.info("Inserted transition: %s", row["reason"])
log.info("Done. %d transition row(s) inserted.", len(planned))
if __name__ == "__main__":
run()
/**
* Detect and report missing paid transitions on the Shopware 6 order_transaction
* state machine (shopware/shopware issue #5795).
*
* Some installs are missing a transition row from paid_partially or reminded to paid,
* because those paths only exist under the action name "pay" and the core
* StateMachineTransitionActions class has no ACTION_PAY constant. This lists every
* transition row, plans any missing (fromState -> paid) pairs with a pure function,
* and reports them. It only inserts a repair row when both DRY_RUN is false and the
* operator explicitly sets ALLOW_REPAIR=true. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-transaction-missing-paid-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 ALLOW_REPAIR = (process.env.ALLOW_REPAIR || "false").toLowerCase() === "true";
export const DEFAULT_REQUIRED_FROM_STATES = ["open", "failed", "paid_partially", "reminded"];
export function planPaidTransitionRepair(transitions, stateIdsByTechnicalName, requiredFromStates = DEFAULT_REQUIRED_FROM_STATES) {
const planned = [];
for (const name of requiredFromStates) {
const covered = transitions.some(
(t) => t.fromStateTechnicalName === name && t.toStateTechnicalName === "paid" && ["pay", "paid"].includes(t.actionName)
);
if (!covered) {
planned.push({
fromStateId: stateIdsByTechnicalName[name],
toStateId: stateIdsByTechnicalName["paid"],
actionName: "paid",
reason: `missing ${name}->paid transition`,
});
}
}
return planned;
}
function headers(token) {
return { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" };
}
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: headers(token),
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) : {};
}
async function getStateMachineId(token) {
const body = { filter: [{ type: "equals", field: "technicalName", value: "order_transaction.state" }] };
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine search`);
const data = await res.json();
return data.data[0].id;
}
async function getStatesByTechnicalName(token, stateMachineId) {
const body = { filter: [{ type: "equals", field: "stateMachineId", value: stateMachineId }], limit: 100 };
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-state`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-state search`);
const data = await res.json();
const map = {};
for (const row of data.data) map[row.technicalName] = row.id;
return map;
}
async function getTransitions(token, stateMachineId) {
const body = {
filter: [{ type: "equals", field: "stateMachineId", value: stateMachineId }],
associations: { fromStateMachineState: {}, toStateMachineState: {} },
page: 1,
limit: 500,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, { method: "POST", headers: headers(token), body: JSON.stringify(body) });
if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-transition search`);
const data = await res.json();
return data.data.map((row) => ({
id: row.id,
fromStateTechnicalName: row.fromStateMachineState.technicalName,
toStateTechnicalName: row.toStateMachineState.technicalName,
actionName: row.actionName,
}));
}
async function insertTransition(token, stateMachineId, plannedRow) {
const body = {
stateMachineId,
actionName: plannedRow.actionName,
fromStateId: plannedRow.fromStateId,
toStateId: plannedRow.toStateId,
};
return apiPost("/api/state-machine-transition", token, body);
}
export async function run() {
const token = await getToken();
const stateMachineId = await getStateMachineId(token);
const stateIds = await getStatesByTechnicalName(token, stateMachineId);
const transitions = await getTransitions(token, stateMachineId);
const planned = planPaidTransitionRepair(transitions, stateIds);
if (planned.length === 0) {
console.log("No missing paid transitions found. Every required state can reach paid.");
return;
}
for (const row of planned) console.warn(`Missing paid transition: ${row.reason}`);
if (!ALLOW_REPAIR) {
console.log(`${planned.length} missing pair(s) reported. Set ALLOW_REPAIR=true and DRY_RUN=false to insert them.`);
return;
}
if (DRY_RUN) {
for (const row of planned) console.log("DRY_RUN: would insert", row);
return;
}
for (const row of planned) {
await insertTransition(token, stateMachineId, row);
console.log(`Inserted transition: ${row.reason}`);
}
console.log(`Done. ${planned.length} transition row(s) inserted.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which pairs get reported, and eventually written, against a live state machine. Because planPaidTransitionRepair is pure and takes the transition list and state id map as plain arguments, the test needs no network and no Shopware store. It just feeds in fixture transition arrays that reproduce issue 5795 and checks the answer.
from check_paid_transition import plan_paid_transition_repair
STATE_IDS = {
"open": "id-open",
"failed": "id-failed",
"paid_partially": "id-paid-partially",
"reminded": "id-reminded",
"paid": "id-paid",
}
def transition(from_name, to_name, action):
return {"id": f"{from_name}-{to_name}-{action}", "fromStateTechnicalName": from_name,
"toStateTechnicalName": to_name, "actionName": action}
def test_reproduces_issue_5795_paid_partially_and_reminded_missing():
transitions = [
transition("open", "paid", "paid"),
transition("failed", "paid", "paid"),
# paid_partially -> paid and reminded -> paid are absent entirely
]
planned = plan_paid_transition_repair(transitions, STATE_IDS)
reasons = {p["reason"] for p in planned}
assert reasons == {"missing paid_partially->paid transition", "missing reminded->paid transition"}
assert len(planned) == 2
def test_no_false_positive_when_action_is_spelled_pay():
transitions = [
transition("open", "paid", "paid"),
transition("failed", "paid", "paid"),
transition("paid_partially", "paid", "pay"),
transition("reminded", "paid", "pay"),
]
planned = plan_paid_transition_repair(transitions, STATE_IDS)
assert planned == []
def test_idempotent_when_everything_already_covered():
transitions = [transition(name, "paid", "paid") for name in ("open", "failed", "paid_partially", "reminded")]
assert plan_paid_transition_repair(transitions, STATE_IDS) == []
def test_planned_row_uses_action_name_paid_and_correct_ids():
transitions = [transition("open", "paid", "paid"), transition("failed", "paid", "paid"), transition("reminded", "paid", "pay")]
planned = plan_paid_transition_repair(transitions, STATE_IDS)
assert planned == [{
"fromStateId": "id-paid-partially",
"toStateId": "id-paid",
"actionName": "paid",
"reason": "missing paid_partially->paid transition",
}]
def test_respects_custom_required_from_states():
transitions = []
planned = plan_paid_transition_repair(transitions, STATE_IDS, required_from_states=["open"])
assert planned == [{
"fromStateId": "id-open",
"toStateId": "id-paid",
"actionName": "paid",
"reason": "missing open->paid transition",
}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { planPaidTransitionRepair } from "./check-paid-transition.js";
const STATE_IDS = {
open: "id-open",
failed: "id-failed",
paid_partially: "id-paid-partially",
reminded: "id-reminded",
paid: "id-paid",
};
function transition(fromName, toName, action) {
return { id: `${fromName}-${toName}-${action}`, fromStateTechnicalName: fromName, toStateTechnicalName: toName, actionName: action };
}
test("reproduces issue 5795, paid_partially and reminded missing", () => {
const transitions = [
transition("open", "paid", "paid"),
transition("failed", "paid", "paid"),
];
const planned = planPaidTransitionRepair(transitions, STATE_IDS);
const reasons = new Set(planned.map((p) => p.reason));
assert.deepEqual(reasons, new Set(["missing paid_partially->paid transition", "missing reminded->paid transition"]));
assert.equal(planned.length, 2);
});
test("no false positive when action is spelled pay", () => {
const transitions = [
transition("open", "paid", "paid"),
transition("failed", "paid", "paid"),
transition("paid_partially", "paid", "pay"),
transition("reminded", "paid", "pay"),
];
assert.deepEqual(planPaidTransitionRepair(transitions, STATE_IDS), []);
});
test("idempotent when everything already covered", () => {
const transitions = ["open", "failed", "paid_partially", "reminded"].map((name) => transition(name, "paid", "paid"));
assert.deepEqual(planPaidTransitionRepair(transitions, STATE_IDS), []);
});
test("planned row uses action name paid and correct ids", () => {
const transitions = [transition("open", "paid", "paid"), transition("failed", "paid", "paid"), transition("reminded", "paid", "pay")];
const planned = planPaidTransitionRepair(transitions, STATE_IDS);
assert.deepEqual(planned, [{
fromStateId: "id-paid-partially",
toStateId: "id-paid",
actionName: "paid",
reason: "missing paid_partially->paid transition",
}]);
});
test("respects custom required from states", () => {
const planned = planPaidTransitionRepair([], STATE_IDS, ["open"]);
assert.deepEqual(planned, [{
fromStateId: "id-open",
toStateId: "id-paid",
actionName: "paid",
reason: "missing open->paid transition",
}]);
});
Case studies
The B2B store where the final invoice payment kept failing
A wholesale store let buyers pay a deposit up front and the balance later, which put the order_transaction into paid_partially while it waited. A custom accounting integration called the paid action once the balance cleared, and it worked in staging but threw in production for every single order.
Running the detection script against production listed the transition rows and found that paid_partially to paid existed there only under the action name pay, a leftover from an older Shopware version. The team confirmed the naming, opted in to the guarded insert, and the same integration code started working without any change to the integration itself.
Reminded orders that could never be marked paid
A subscription shop used Shopware's built-in payment reminder flow, which moves a transaction to reminded after a missed renewal. When a customer finally paid after receiving a reminder, support staff tried to mark the transaction paid from the Administration UI and got a plain error with no clear next step.
The detection script's report showed reminded to paid was entirely absent from that shop's customized state machine, not just misnamed. Rather than guess at a fix, the team used the report as a checklist item for their next state machine review, and routed those specific orders through cancel and a fresh transaction until the missing row was confirmed and added on purpose.
After running this, you know exactly which fromState to paid pairs are missing on your shop's order_transaction state machine, and under which action name the ones that do exist are stored. Nothing gets written until a human reads the report and opts in on purpose, so the fix respects whatever the shop's own customizations intended instead of guessing. Once the pair is confirmed and inserted, the original workflow that threw now completes exactly like every other payment path.
FAQ
Why does calling the paid action on an order_transaction throw an error?
Shopware 6 only allows a state change when a matching row exists in state_machine_transition for that exact fromState and actionName pair. On some installs, paid_partially to paid and reminded to paid exist only under the action name pay, not paid, and the core PHP class StateMachineTransitionActions has no ACTION_PAY constant, so code or customizations that call the paid action from those states find no transition row and the state machine registry throws.
Is pay or paid the correct action name for reaching the paid state in Shopware 6?
Both spellings exist across Shopware 6 installs depending on version and customization history, which is exactly the confusion behind GitHub issue 5795. The only reliable approach is to read the actual actionName values stored in state_machine_transition for your shop rather than assume either constant, since the core StateMachineTransitionActions class only defines ACTION_PAID and has no ACTION_PAY constant to check against.
Is it safe to insert a missing state_machine_transition row automatically?
Not by default. Inserting a row changes core workflow behavior, and issue 5795 shows Shopware itself has been inconsistent about whether the action should be pay or paid. The safe default is to detect and report the missing fromState to paid pairs so a human can confirm them against any customizations, then insert only with an explicit opt in, guarded by DRY_RUN.
Related field notes
Citations
On the problem:
- Missing state_machine_transition "paid" for some order_transaction transitions, GitHub issue #5795. github.com/shopware/shopware/issues/5795
- SW 6.7 Migration renames all "pay"/"pay_partially"/"do_pay" state machine transitions, GitHub issue #11157. github.com/shopware/shopware/issues/11157
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
On the solution:
- Shopware Admin API Reference (Stoplight): StateMachineTransition. shopware.stoplight.io/docs/admin-api/d2d3b29ba06a8-state-machine-transition
- Shopware Admin API Reference (Stoplight): StateMachine. shopware.stoplight.io/docs/admin-api/4f023ac2306d8-state-machine
- Shopware Admin API Reference (Stoplight): StateMachineState. shopware.stoplight.io/docs/admin-api/8af19ce5b5a52-state-machine-state
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.
Did this clear up a missing transition?
If this saved you a confusing state machine error or a guess at the wrong action name, 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