Diagnostic Order State Machine
Payment state history not updated after switching payment method post-failure
A customer's card is declined at PayPal, or the external provider times out, and the checkout lets them pick a different payment method to try again. The order goes on to get paid, or it does not, but the audit trail never says so. The order_transaction's stateMachineState and its state_machine_history rows fall out of sync, flow-builder's "Payment status changed" trigger never fires, and nobody can reconstruct what happened just from looking at the history table. Here is why Shopware 6 leaves that gap and a small script that finds it.
In Shopware 6, when a payment fails at an external provider such as PayPal, the order_transaction is left in a state like open, cancelled, or reminded, and the checkout's change payment method flow (SalesChannel\OrderService::orderChangePayment, the storefront changePaymentMethod route) lets the customer choose a different payment method. That flow updates paymentMethodId on the order and order_transaction, and can create a new order_transaction row, but it does not always reliably drive a transition through OrderTransactionStateHandler. No transition event means no state_machine_history row gets written, so the transaction's current stateMachineState and its history trail disagree, and flow-builder triggers such as "Payment status changed" never fire. This is a confirmed bug, tracked as shopware/shopware issue #7330. Search order-transaction for candidates still sitting on open, cancelled, reminded, or failed, pull each one's state_machine_history, and flag anything with no history, a stale last entry, or a newer sibling transaction with no transition of its own. Full code, tests, and sources are below.
The problem in plain words
Shopware's checkout is built so that every meaningful change to an order_transaction is supposed to leave a trail. The state machine moves the transaction from one named state to another, such as open to paid, and every one of those moves is meant to write a row into state_machine_history recording the from state, the to state, and when it happened. Flow-builder listens for exactly those transition events to fire triggers like "Payment status changed".
The change payment method path breaks that assumption. When a payment fails at PayPal or another external provider, the transaction is left in whatever state the failure produced, and the customer is offered a chance to pick a different payment method and try again. That action goes through orderChangePayment, and its job is really just to change which payment method the order will use next. It updates paymentMethodId on the order, and depending on the version and the payment method, it can create a brand new order_transaction row for the retried payment. What it does not reliably do is call the state machine transition that would move the transaction's state forward and, as a side effect, record the history row that proves it happened. The state can end up updated on the entity while the audit trail says nothing changed, or a second order_transaction can exist for the same order with no transition ever recorded against it at all.
Why it happens
The change payment method action was designed to answer one question, which payment method should this order use, not to fully drive the state machine through every possible outcome of a retried payment. A few common ways stores end up with the gap:
- A customer's PayPal payment fails and leaves the transaction on
open,cancelled, orreminded, then the customer picks a new payment method through the storefront'schangePaymentMethodroute or its admin equivalent, which changespaymentMethodIdwithout transitioning the state. - The change payment method call creates a second order_transaction row for the same order rather than reusing the first, so the order now has multiple transactions and the newest one never gets its own transition or history entry.
- The transaction's
stateIdends up moved, for example by a later manual admin action, but the change happens outside a code path that goes throughOrderTransactionStateHandler, soupdatedAtadvances with no correspondingstate_machine_historyrow. - Flow-builder automations that key off "Payment status changed" silently stop firing for these orders, because the trigger only listens for the transition event, and there was none to hear.
This is confirmed as a real bug in Shopware's own tracker, and it sits next to a related report of missing history entries for the paid transition specifically. Neither is a one-off plugin issue. See the citations at the end for the exact threads and docs.
There is no single correct fix to apply blindly here, because the right target state depends on what the customer actually did after switching payment method, and that cannot be reconstructed from the transaction's current state alone. So the safe move is to detect and report the gap rather than guess at a repair. A transaction only qualifies for the clearer, DRY_RUN-guarded corrective path when the order itself is still open and the transaction is stuck on a stale terminal-ish state like cancelled or failed, in which case the safe repair is the same one Shopware's own UI would use: a state machine transition action, never a direct write to stateId.
The fix, as a flow
We never touch stateId, and we never guess at what a customer's retried payment resolved to. The script searches order-transaction for candidates in open, cancelled, reminded, or failed, pulls each one's state_machine_history, and groups by order to catch the multi-transaction case. Anything with no history, a stale last entry, or a newer sibling transaction missing its own transition gets flagged. Nothing is written unless a human confirms it.
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 to write
// 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 to write
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 both search requests and, later, for the guarded transition action call.
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 candidate transactions and their history
Search order-transaction for anything sitting on open, cancelled, reminded, or failed, with the order and stateMachineState associations. For every candidate, pull its state-machine-history rows filtered by entityId.id and entityName, oldest first.
CANDIDATE_STATES = ["open", "cancelled", "reminded", "failed"]
def search_candidate_transactions(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equalsAny", "field": "stateMachineState.technicalName", "value": CANDIDATE_STATES}],
"associations": {"order": {}, "stateMachineState": {}},
"sort": [{"field": "createdAt", "order": "DESC"}],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-transaction",
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 fetch_history(token, transaction_id):
body = {
"filter": [
{"type": "equals", "field": "entityId.id", "value": transaction_id},
{"type": "equals", "field": "entityName", "value": "order_transaction"},
],
"sort": [{"field": "createdAt", "order": "ASC"}],
"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"]
const CANDIDATE_STATES = ["open", "cancelled", "reminded", "failed"];
async function searchCandidateTransactions(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "equalsAny", field: "stateMachineState.technicalName", value: CANDIDATE_STATES }],
associations: { order: {}, stateMachineState: {} },
sort: [{ field: "createdAt", order: "DESC" }],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-transaction`, {
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 order-transaction search`);
const data = await res.json();
return data.data;
}
async function fetchHistory(token, transactionId) {
const body = {
filter: [
{ type: "equals", field: "entityId.id", value: transactionId },
{ type: "equals", field: "entityName", value: "order_transaction" },
],
sort: [{ field: "createdAt", order: "ASC" }],
"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;
}
Decide, with one pure function
Keep the decision separate from any network call. Group transactions by orderId, then for each one check three things against its history: is there no history at all, does the last history entry disagree with the current stateId, and if the order has multiple transactions, does the newest one have no history entry created after it. Nothing here talks to the network, so it is trivial to test with fixture arrays and maps.
from collections import defaultdict
def find_history_gaps(transactions, history_by_transaction_id):
by_order = defaultdict(list)
for tx in transactions:
by_order[tx["orderId"]].append(tx)
gaps = []
for order_id, txs in by_order.items():
txs_sorted = sorted(txs, key=lambda t: t["createdAt"])
newest = txs_sorted[-1]
multi = len(txs_sorted) > 1
for tx in txs_sorted:
history = history_by_transaction_id.get(tx["id"]) or []
if not history:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "no_history"})
continue
last_entry = history[-1]
if last_entry["toStateId"] != tx["stateId"]:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "stale_state"})
continue
if multi and tx["id"] == newest["id"]:
has_transition_after_creation = any(h["createdAt"] > tx["createdAt"] for h in history)
if not has_transition_after_creation:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "multi_transaction_no_transition"})
return gaps
export function findHistoryGaps(transactions, historyByTransactionId) {
const byOrder = new Map();
for (const tx of transactions) {
if (!byOrder.has(tx.orderId)) byOrder.set(tx.orderId, []);
byOrder.get(tx.orderId).push(tx);
}
const gaps = [];
for (const [orderId, txs] of byOrder) {
const txsSorted = [...txs].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const newest = txsSorted[txsSorted.length - 1];
const multi = txsSorted.length > 1;
for (const tx of txsSorted) {
const history = historyByTransactionId.get(tx.id) || [];
if (history.length === 0) {
gaps.push({ transactionId: tx.id, orderId, reason: "no_history" });
continue;
}
const lastEntry = history[history.length - 1];
if (lastEntry.toStateId !== tx.stateId) {
gaps.push({ transactionId: tx.id, orderId, reason: "stale_state" });
continue;
}
if (multi && tx.id === newest.id) {
const hasTransitionAfterCreation = history.some((h) => h.createdAt > tx.createdAt);
if (!hasTransitionAfterCreation) {
gaps.push({ transactionId: tx.id, orderId, reason: "multi_transaction_no_transition" });
}
}
}
}
return gaps;
}
Report first, repair only under a confirm flag
By default the script only logs the flagged gaps. For the one clear repair case, a transaction stuck on a stale terminal-ish state while its order is still open, we compute the available transition from Shopware's own state machine and call the transition action, never a PATCH of stateId. That call both moves the state and writes the missing history row. It only runs when DRY_RUN is false and an explicit confirm flag is set.
def transition_route(transaction_id, transition):
return f"/api/_action/order-transaction/{transaction_id}/state/{transition}"
def run_transition(token, transaction_id, transition):
return api_post(transition_route(transaction_id, transition), token, body={})
function transitionRoute(transactionId, transition) {
return `/api/_action/order-transaction/${transactionId}/state/${transition}`;
}
async function runTransition(token, transactionId, transition) {
return apiPost(transitionRoute(transactionId, transition), token, {});
}
Wire it together with a dry run guard
The loop authenticates, searches candidates, fetches history for each, runs find_history_gaps, and logs every gap it finds. Reads happen either way, so the report is always available. Writes never happen unless DRY_RUN is explicitly off and CONFIRM_REPAIR is explicitly set.
Never patch stateId, and never assume a target state for a transaction you cannot fully reconstruct. Start with DRY_RUN=true, review every flagged gap, and only enable a corrective transition for the narrow case this script actually understands. Everything else stays a report for a human to read.
The full code
Here is the complete script in one file for each language. It authenticates, searches order-transaction for candidates, fetches state_machine_history for each one, flags gaps with the pure function, and only ever transitions state through the state machine action, gated by DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Flag Shopware 6 order-transactions whose state_machine_history fell out of
sync with their current state after a customer switched payment method
following a failed payment, without ever patching stateId directly.
When a payment fails at an external provider and the customer picks a different
payment method, the change payment method flow updates paymentMethodId and can
create a new order_transaction, but it does not always reliably drive a state
machine transition. No transition means no state_machine_history row, so the
transaction's current state and its audit trail disagree.
Searches order-transaction for candidates still open, cancelled, reminded, or
failed, fetches each one's state_machine_history, and flags a gap when there is
no history, the last history entry disagrees with the current state, or the
newest transaction on a multi-transaction order has no transition of its own.
Reports by default. Only runs a corrective transition when DRY_RUN is false and
CONFIRM_REPAIR is explicitly set. 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("flag_history_gaps")
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"
CONFIRM_REPAIR = os.environ.get("CONFIRM_REPAIR", "false").lower() == "true"
CANDIDATE_STATES = ["open", "cancelled", "reminded", "failed"]
STALE_TERMINAL_STATES = {"cancelled", "failed"}
def find_history_gaps(transactions, history_by_transaction_id):
"""Pure decision. No I/O. Takes plain arrays and a map, returns plain gaps."""
by_order = {}
for tx in transactions:
by_order.setdefault(tx["orderId"], []).append(tx)
gaps = []
for order_id, txs in by_order.items():
txs_sorted = sorted(txs, key=lambda t: t["createdAt"])
newest = txs_sorted[-1]
multi = len(txs_sorted) > 1
for tx in txs_sorted:
history = history_by_transaction_id.get(tx["id"]) or []
if not history:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "no_history"})
continue
last_entry = history[-1]
if last_entry["toStateId"] != tx["stateId"]:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "stale_state"})
continue
if multi and tx["id"] == newest["id"]:
has_transition_after_creation = any(h["createdAt"] > tx["createdAt"] for h in history)
if not has_transition_after_creation:
gaps.append({"transactionId": tx["id"], "orderId": order_id, "reason": "multi_transaction_no_transition"})
return gaps
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 search_candidate_transactions(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equalsAny", "field": "stateMachineState.technicalName", "value": CANDIDATE_STATES}],
"associations": {"order": {}, "stateMachineState": {}},
"sort": [{"field": "createdAt", "order": "DESC"}],
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order-transaction",
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 fetch_history(token, transaction_id):
body = {
"filter": [
{"type": "equals", "field": "entityId.id", "value": transaction_id},
{"type": "equals", "field": "entityName", "value": "order_transaction"},
],
"sort": [{"field": "createdAt", "order": "ASC"}],
"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 run_transition(token, transaction_id, transition):
return api_post(f"/api/_action/order-transaction/{transaction_id}/state/{transition}", token, body={})
def run():
token = get_token()
candidates = search_candidate_transactions(token)
transactions = [
{
"id": tx["id"],
"orderId": tx["orderId"],
"paymentMethodId": tx.get("paymentMethodId"),
"stateId": tx["stateId"],
"technicalName": (tx.get("stateMachineState") or {}).get("technicalName"),
"createdAt": tx["createdAt"],
"updatedAt": tx.get("updatedAt") or tx["createdAt"],
}
for tx in candidates
]
history_by_transaction_id = {}
for tx in transactions:
history = fetch_history(token, tx["id"])
history_by_transaction_id[tx["id"]] = [
{"toStateId": h["toStateId"], "createdAt": h["createdAt"]} for h in history
]
gaps = find_history_gaps(transactions, history_by_transaction_id)
by_id = {tx["id"]: tx for tx in transactions}
repaired = 0
for gap in gaps:
tx = by_id[gap["transactionId"]]
log.warning(
"Gap: order %s, transaction %s, reason=%s, state=%s",
gap["orderId"], gap["transactionId"], gap["reason"], tx["technicalName"],
)
eligible_for_repair = tx["technicalName"] in STALE_TERMINAL_STATES and gap["reason"] in (
"stale_state", "multi_transaction_no_transition",
)
if not eligible_for_repair:
continue
transition = "reopen"
log.info(
"Repair candidate: transaction %s. %s transition '%s'",
tx["id"], "would run" if (DRY_RUN or not CONFIRM_REPAIR) else "running", transition,
)
if not DRY_RUN and CONFIRM_REPAIR:
run_transition(token, tx["id"], transition)
repaired += 1
log.info("Done. %d gap(s) flagged, %d repaired.", len(gaps), repaired)
if __name__ == "__main__":
run()
/**
* Flag Shopware 6 order-transactions whose state_machine_history fell out of
* sync with their current state after a customer switched payment method
* following a failed payment, without ever patching stateId directly.
*
* When a payment fails at an external provider and the customer picks a different
* payment method, the change payment method flow updates paymentMethodId and can
* create a new order_transaction, but it does not always reliably drive a state
* machine transition. No transition means no state_machine_history row, so the
* transaction's current state and its audit trail disagree.
*
* Searches order-transaction for candidates still open, cancelled, reminded, or
* failed, fetches each one's state_machine_history, and flags a gap when there is
* no history, the last history entry disagrees with the current state, or the
* newest transaction on a multi-transaction order has no transition of its own.
* Reports by default. Only runs a corrective transition when DRY_RUN is false and
* CONFIRM_REPAIR is explicitly set. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/payment-method-switch-history-gap/
*/
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 CONFIRM_REPAIR = (process.env.CONFIRM_REPAIR || "false").toLowerCase() === "true";
const CANDIDATE_STATES = ["open", "cancelled", "reminded", "failed"];
const STALE_TERMINAL_STATES = new Set(["cancelled", "failed"]);
export function findHistoryGaps(transactions, historyByTransactionId) {
const byOrder = new Map();
for (const tx of transactions) {
if (!byOrder.has(tx.orderId)) byOrder.set(tx.orderId, []);
byOrder.get(tx.orderId).push(tx);
}
const gaps = [];
for (const [orderId, txs] of byOrder) {
const txsSorted = [...txs].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const newest = txsSorted[txsSorted.length - 1];
const multi = txsSorted.length > 1;
for (const tx of txsSorted) {
const history = historyByTransactionId.get(tx.id) || [];
if (history.length === 0) {
gaps.push({ transactionId: tx.id, orderId, reason: "no_history" });
continue;
}
const lastEntry = history[history.length - 1];
if (lastEntry.toStateId !== tx.stateId) {
gaps.push({ transactionId: tx.id, orderId, reason: "stale_state" });
continue;
}
if (multi && tx.id === newest.id) {
const hasTransitionAfterCreation = history.some((h) => h.createdAt > tx.createdAt);
if (!hasTransitionAfterCreation) {
gaps.push({ transactionId: tx.id, orderId, reason: "multi_transaction_no_transition" });
}
}
}
}
return gaps;
}
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) : {};
}
async function searchCandidateTransactions(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "equalsAny", field: "stateMachineState.technicalName", value: CANDIDATE_STATES }],
associations: { order: {}, stateMachineState: {} },
sort: [{ field: "createdAt", order: "DESC" }],
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order-transaction`, {
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 order-transaction search`);
const data = await res.json();
return data.data;
}
async function fetchHistory(token, transactionId) {
const body = {
filter: [
{ type: "equals", field: "entityId.id", value: transactionId },
{ type: "equals", field: "entityName", value: "order_transaction" },
],
sort: [{ field: "createdAt", order: "ASC" }],
"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;
}
async function runTransition(token, transactionId, transition) {
return apiPost(`/api/_action/order-transaction/${transactionId}/state/${transition}`, token, {});
}
export async function run() {
const token = await getToken();
const candidates = await searchCandidateTransactions(token);
const transactions = candidates.map((tx) => ({
id: tx.id,
orderId: tx.orderId,
paymentMethodId: tx.paymentMethodId,
stateId: tx.stateId,
technicalName: tx.stateMachineState?.technicalName,
createdAt: tx.createdAt,
updatedAt: tx.updatedAt || tx.createdAt,
}));
const historyByTransactionId = new Map();
for (const tx of transactions) {
const history = await fetchHistory(token, tx.id);
historyByTransactionId.set(
tx.id,
history.map((h) => ({ toStateId: h.toStateId, createdAt: h.createdAt }))
);
}
const gaps = findHistoryGaps(transactions, historyByTransactionId);
const byId = new Map(transactions.map((tx) => [tx.id, tx]));
let repaired = 0;
for (const gap of gaps) {
const tx = byId.get(gap.transactionId);
console.warn(`Gap: order ${gap.orderId}, transaction ${gap.transactionId}, reason=${gap.reason}, state=${tx.technicalName}`);
const eligibleForRepair =
STALE_TERMINAL_STATES.has(tx.technicalName) &&
(gap.reason === "stale_state" || gap.reason === "multi_transaction_no_transition");
if (!eligibleForRepair) continue;
const transition = "reopen";
console.log(`Repair candidate: transaction ${tx.id}. ${DRY_RUN || !CONFIRM_REPAIR ? "would run" : "running"} transition '${transition}'`);
if (!DRY_RUN && CONFIRM_REPAIR) {
await runTransition(token, tx.id, transition);
repaired++;
}
}
console.log(`Done. ${gaps.length} gap(s) flagged, ${repaired} repaired.`);
}
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 transactions get reported, and eventually which ones qualify for a guarded repair. Because find_history_gaps is pure and takes plain arrays and a map, the test needs no network and no Shopware store.
from flag_history_gaps import find_history_gaps
def tx(**over):
base = {
"id": "tx-1", "orderId": "order-1", "paymentMethodId": "pm-1",
"stateId": "state-open", "createdAt": "2026-07-10T10:00:00Z", "updatedAt": "2026-07-10T10:00:00Z",
}
base.update(over)
return base
def test_no_gap_when_history_matches_current_state():
history = {"tx-1": [{"toStateId": "state-open", "createdAt": "2026-07-10T10:00:01Z"}]}
assert find_history_gaps([tx()], history) == []
def test_no_history_is_flagged():
result = find_history_gaps([tx()], {})
assert result == [{"transactionId": "tx-1", "orderId": "order-1", "reason": "no_history"}]
def test_stale_last_history_entry_is_flagged():
history = {"tx-1": [{"toStateId": "state-cancelled", "createdAt": "2026-07-10T09:00:00Z"}]}
result = find_history_gaps([tx(stateId="state-open")], history)
assert result == [{"transactionId": "tx-1", "orderId": "order-1", "reason": "stale_state"}]
def test_multi_transaction_newest_without_transition_is_flagged():
older = tx(id="tx-old", createdAt="2026-07-10T09:00:00Z", stateId="state-cancelled")
newest = tx(id="tx-new", createdAt="2026-07-10T11:00:00Z", stateId="state-open")
history = {
"tx-old": [{"toStateId": "state-cancelled", "createdAt": "2026-07-10T09:00:01Z"}],
"tx-new": [{"toStateId": "state-open", "createdAt": "2026-07-10T10:00:00Z"}], # before creation
}
result = find_history_gaps([older, newest], history)
assert {"transactionId": "tx-new", "orderId": "order-1", "reason": "stale_state"} in result
def test_multi_transaction_with_transition_after_creation_is_clean():
older = tx(id="tx-old", createdAt="2026-07-10T09:00:00Z", stateId="state-cancelled")
newest = tx(id="tx-new", createdAt="2026-07-10T11:00:00Z", stateId="state-open")
history = {
"tx-old": [{"toStateId": "state-cancelled", "createdAt": "2026-07-10T09:00:01Z"}],
"tx-new": [{"toStateId": "state-open", "createdAt": "2026-07-10T11:00:01Z"}], # after creation
}
result = find_history_gaps([older, newest], history)
assert result == []
def test_single_transaction_ignores_multi_transaction_rule():
history = {"tx-1": [{"toStateId": "state-open", "createdAt": "2026-07-10T10:00:01Z"}]}
result = find_history_gaps([tx()], history)
assert result == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findHistoryGaps } from "./flag-history-gaps.js";
const tx = (over = {}) => ({
id: "tx-1", orderId: "order-1", paymentMethodId: "pm-1",
stateId: "state-open", createdAt: "2026-07-10T10:00:00Z", updatedAt: "2026-07-10T10:00:00Z",
...over,
});
test("no gap when history matches current state", () => {
const history = new Map([["tx-1", [{ toStateId: "state-open", createdAt: "2026-07-10T10:00:01Z" }]]]);
assert.deepEqual(findHistoryGaps([tx()], history), []);
});
test("no history is flagged", () => {
const result = findHistoryGaps([tx()], new Map());
assert.deepEqual(result, [{ transactionId: "tx-1", orderId: "order-1", reason: "no_history" }]);
});
test("stale last history entry is flagged", () => {
const history = new Map([["tx-1", [{ toStateId: "state-cancelled", createdAt: "2026-07-10T09:00:00Z" }]]]);
const result = findHistoryGaps([tx({ stateId: "state-open" })], history);
assert.deepEqual(result, [{ transactionId: "tx-1", orderId: "order-1", reason: "stale_state" }]);
});
test("multi transaction newest without transition is flagged", () => {
const older = tx({ id: "tx-old", createdAt: "2026-07-10T09:00:00Z", stateId: "state-cancelled" });
const newest = tx({ id: "tx-new", createdAt: "2026-07-10T11:00:00Z", stateId: "state-open" });
const history = new Map([
["tx-old", [{ toStateId: "state-cancelled", createdAt: "2026-07-10T09:00:01Z" }]],
["tx-new", [{ toStateId: "state-open", createdAt: "2026-07-10T10:00:00Z" }]], // before creation
]);
const result = findHistoryGaps([older, newest], history);
assert.ok(result.some((g) => g.transactionId === "tx-new" && g.reason === "stale_state"));
});
test("multi transaction with transition after creation is clean", () => {
const older = tx({ id: "tx-old", createdAt: "2026-07-10T09:00:00Z", stateId: "state-cancelled" });
const newest = tx({ id: "tx-new", createdAt: "2026-07-10T11:00:00Z", stateId: "state-open" });
const history = new Map([
["tx-old", [{ toStateId: "state-cancelled", createdAt: "2026-07-10T09:00:01Z" }]],
["tx-new", [{ toStateId: "state-open", createdAt: "2026-07-10T11:00:01Z" }]], // after creation
]);
const result = findHistoryGaps([older, newest], history);
assert.deepEqual(result, []);
});
test("single transaction ignores multi transaction rule", () => {
const history = new Map([["tx-1", [{ toStateId: "state-open", createdAt: "2026-07-10T10:00:01Z" }]]]);
const result = findHistoryGaps([tx()], history);
assert.deepEqual(result, []);
});
Case studies
The support ticket that flow-builder never saw
A subscription box store had a flow set up to email a discount code whenever "Payment status changed" fired for a failed payment, hoping to win the customer back. A support agent noticed a customer who had failed at PayPal, switched to SEPA, and paid successfully days later, yet never got the follow-up email and the order looked stuck in the admin timeline.
Running the detector against order-transaction turned up dozens of transactions with no state_machine_history row at all, all from the same change payment method path. The team could not safely guess what each one resolved to, so they used the report to manually verify each order's real status against the payment provider before touching anything.
Two order_transaction rows, one order, one silent gap
A B2B store noticed the admin order screen sometimes showed a payment status that did not match what finance expected. Investigating one order showed two order_transaction rows: the first from a failed card payment stuck on cancelled, and a second from the customer's retry with a bank transfer, which itself had no history rows younger than its own creation timestamp.
The multi-transaction rule in the pure function caught exactly this shape. The store used the flagged list to identify which orders needed a manual look, and reserved the DRY_RUN-guarded repair only for the handful where the newest transaction's order was clearly still open and the older transaction was cleanly stuck on a terminal state.
After this runs on a schedule, the gap between what an order_transaction's state says and what its history proves stops being invisible. Every candidate gets checked against its own audit trail, multi-transaction orders get the extra scrutiny they need, and nothing gets pushed through a guessed transition. The narrow, DRY_RUN-guarded repair only ever applies to the one case this script can actually reason about, so a report never quietly turns into a wrong write.
FAQ
Why does Shopware 6 not log a state_machine_history row after a customer switches payment method?
When a payment fails at an external provider and the customer picks a different payment method, the change payment method route updates paymentMethodId on the order and order_transaction and can create a new order_transaction record, but it does not always reliably drive a state machine transition through OrderTransactionStateHandler. Because no transition event fires, no row is written to state_machine_history, so the transaction's current state and its audit trail fall out of sync.
Is it safe to auto-fix the missing state_machine_history row with a script?
Not fully automatically, because there is no single correct target state. The right transition, such as reopen, process, or paid, depends on what the customer actually did after switching payment method, which cannot be reconstructed from state alone. Treat it as a flag and report tool, and only fire a corrective transition under a DRY_RUN guard with an explicit confirm step for the clear case of a stale terminal-ish state on a transaction whose order is still open.
Why can I not just PATCH order_transaction.stateId to fix the mismatch?
Shopware write protects stateId for the crud scope on every state machine backed entity, so a direct PATCH is rejected, and even if it were not, patching the column directly would still skip writing a state_machine_history row. The only supported way to both move stateId and record the missing history entry is a transition action, such as POST /api/_action/order-transaction/{id}/state/reopen, which runs through the StateMachineRegistry.
Related field notes
Citations
On the problem:
- Payment history not set correctly when customer changes payment method after failed payment with external payment method (shopware/shopware #7330). github.com/shopware/shopware/issues/7330
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
- Missing state_machine_transition "paid" for some order_transaction transitions (shopware/shopware #5795). github.com/shopware/shopware/issues/5795
On the solution:
- Shopware Admin API: Order Management. shopware.stoplight.io/docs/admin-api/fdd24cc76f22d-order-management
- Shopware Admin API: OrderTransaction. shopware.stoplight.io/docs/admin-api/01f2ced432963-order-transaction
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
Stuck on a tricky one?
If you have a problem in Shopware orders, payments, the state machine, 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 payment history mystery?
If this saved you a confusing support ticket or a flow that quietly stopped firing, 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