Reconciler Order State Machine
Order transaction stuck open despite a completed payment
A customer pays through an async payment method, the PSP dashboard shows the charge as settled, and support has the confirmation email to prove it. But the order in Shopware still shows the transaction as open. Nothing failed loudly. No error was ever seen, because the request that should have called the transition either lost a race or never ran. Here is why Shopware's async payment flow can drop that call and a small script that finds the mismatch, checks the PSP as the source of truth, and repairs it the correct way.
Shopware 6's async payment flow assumes exactly one request will consume a given payment token, either the PSP's webhook or notify callback, or the customer's browser redirect back to /payment/finalize-transaction, which calls PaymentProcessor::finalize(). In practice both requests can arrive, and whichever one wins the race can invalidate the token or short-circuit the transition before the other completes. Combined with browser refreshes, back-button navigation, and mobile banking apps reloading the return page, the transition to paid gets skipped, and order_transaction.stateMachineState.technicalName is left stuck at open even though the PSP confirms the payment. Search for transactions still open past a safety window, cross-check each one's PSP reference against the PSP's own API, and only when the PSP is authoritatively paid call POST /api/_action/order_transaction/{id}/state/paid, gated by DRY_RUN. Full code, tests, and sources are below.
The problem in plain words
Most Shopware payment methods that redirect to a third party, like Mollie, Stripe, PayPal, or a bank's own payment page, work asynchronously. The customer leaves the shop, pays somewhere else, and then two things are supposed to happen: the PSP calls a webhook or notify URL to tell Shopware the payment finished, and the customer's browser is redirected back to /payment/finalize-transaction, which runs PaymentProcessor::finalize() and moves the order_transaction to paid or cancelled.
The flow is built assuming only one of those two requests will ever actually do the finalizing work for a given payment token. But nothing stops both from arriving. Whichever one gets there first can consume or invalidate the token, so when the second request shows up moments later it finds a token that is already spent and either errors quietly or short-circuits without transitioning anything. Add a customer who refreshes the return page, hits the back button and lands on it again, or a mobile banking app that reopens the browser and reloads the redirect URL, and the odds of two requests racing for the same token go up. When the loser of that race was the one request that would have called the transition, the order_transaction just sits on open forever, because Shopware never lets code patch stateId directly, and if the transition action is never called, nothing else will call it for you.
Why it happens
The async payment flow is built around a token that represents one pending payment, and the two consumers of that token, the webhook and the browser redirect, were designed to be redundant paths to the same outcome rather than a strict sequence. A few common ways stores end up with a stuck transaction:
- The customer's browser reaches
/payment/finalize-transactiona split second before the PSP's webhook fires, or the reverse, and whichever request arrives second finds the token already consumed. - The customer refreshes the return page or uses the back button and lands on the finalize URL again, replaying a request against a token that was already spent by the first pass.
- A mobile banking app opens its own in-app browser for the payment step, then hands control back in a way that reloads the return page, effectively firing a second finalize request Shopware never expected.
- The request that should have called the transition throws partway through, for example a timeout talking to the PSP to verify the token, and nothing retries it, so the transition simply never happens.
This is a known sharp edge in the payment flow, not a one-off plugin bug. Community threads against several PSP integrations describe orders left unpaid locally despite the PSP showing the charge as settled, and Shopware's own architecture decision record for the payment flow acknowledges the webhook and the return URL as two independent paths into the same finalize logic. See the citations at the end for the exact threads and docs.
You cannot fix a race after the fact by making the code faster or adding a retry to the request that lost. The token is gone, the moment has passed, and Shopware will never call the transition on its own once both requests have come and gone. The only correct move is to detect the mismatch afterward: read the PSP's own status as the source of truth, wait long enough that you are not racing a webhook that is still in flight, and then call the transition action yourself. That keeps the fix outside the fragile timing window entirely.
The fix, as a flow
We never touch stateId, and we never guess. The script searches for order-transactions still open, reads each one's PSP reference from customFields, calls the PSP's own API to ask what actually happened, and only transitions the ones where the PSP is unambiguously paid and the local record has sat still long enough to rule out an in-flight webhook. Everything else, including anything the PSP cannot confirm, is flagged for a human instead of being pushed through blind.
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 MIN_AGE_MINUTES="15" # how long a transaction must sit open before we touch it
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 MIN_AGE_MINUTES="15" // how long a transaction must sit open before we touch it
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 the search request and for the 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 stuck transactions
Search order-transaction for anything still open, with the order and stateMachineState associations, sorted oldest first. Restrict to orders older than roughly an hour so you never look at a payment that is still legitimately in flight.
def search_open_transactions(token, limit=100):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "open"}],
"associations": {"order": {}, "stateMachineState": {}},
"sort": [{"field": "createdAt", "order": "ASC"}],
"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"]
async function searchOpenTransactions(token, limit = 100) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "open" }],
associations: { order: {}, stateMachineState: {} },
sort: [{ field: "createdAt", order: "ASC" }],
"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;
}
Decide, with one pure function
Keep the decision separate from any network call. Given the local state snapshot, a pre-fetched PSP status, the current time, and a minimum age in minutes, decide whether to transition to paid, transition to cancelled, flag for review, or do nothing. Nothing here talks to the network, so it is trivial to test with fixed clocks and fixture states.
from datetime import datetime, timezone
def _parse(iso):
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def minutes_between(later_iso, earlier_iso):
return (_parse(later_iso) - _parse(earlier_iso)).total_seconds() / 60
def decide_transaction_repair(local_state, psp_status, now, min_age_minutes=15):
if local_state["technicalName"] != "open":
return {"action": "no_action", "reason": "already resolved"}
if minutes_between(now, local_state["updatedAt"]) < min_age_minutes:
return {"action": "no_action", "reason": "too fresh, let webhook race resolve naturally"}
if psp_status["isPaid"]:
return {"action": "transition_paid", "reason": "PSP confirms paid, local stuck open"}
if psp_status["isFailed"]:
return {"action": "transition_cancel", "reason": "PSP confirms failed/cancelled, local stuck open"}
return {"action": "flag_review", "reason": "PSP status inconclusive, needs human check"}
function minutesBetween(laterIso, earlierIso) {
return (Date.parse(laterIso) - Date.parse(earlierIso)) / 60000;
}
export function decideTransactionRepair(localState, pspStatus, now, minAgeMinutes = 15) {
if (localState.technicalName !== "open") {
return { action: "no_action", reason: "already resolved" };
}
if (minutesBetween(now, localState.updatedAt) < minAgeMinutes) {
return { action: "no_action", reason: "too fresh, let webhook race resolve naturally" };
}
if (pspStatus.isPaid) {
return { action: "transition_paid", reason: "PSP confirms paid, local stuck open" };
}
if (pspStatus.isFailed) {
return { action: "transition_cancel", reason: "PSP confirms failed/cancelled, local stuck open" };
}
return { action: "flag_review", reason: "PSP status inconclusive, needs human check" };
}
Call the transition action, never a PATCH
When the decision is transition_paid, call POST /api/_action/order_transaction/{id}/state/paid with an empty body. Shopware resolves the correct edge from the current state via the state machine definition, so you never have to know the exact intermediate step. Re-fetch the transaction afterward to confirm stateMachineState.technicalName is now paid before marking it resolved.
def transition_route(order_transaction_id, transition):
return f"/api/_action/order_transaction/{order_transaction_id}/state/{transition}"
def run_transition(token, order_transaction_id, transition):
return api_post(transition_route(order_transaction_id, transition), token, body={})
def get_transaction(token, order_transaction_id):
r = requests.get(
f"{SHOPWARE_URL}/api/order-transaction/{order_transaction_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
function transitionRoute(orderTransactionId, transition) {
return `/api/_action/order_transaction/${orderTransactionId}/state/${transition}`;
}
async function runTransition(token, orderTransactionId, transition) {
return apiPost(transitionRoute(orderTransactionId, transition), token, {});
}
async function getTransaction(token, orderTransactionId) {
const res = await fetch(`${SHOPWARE_URL}/api/order-transaction/${orderTransactionId}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Shopware ${res.status} fetching order-transaction`);
const data = await res.json();
return data.data;
}
Wire it together with a dry run guard
The loop ties every piece together. For each candidate transaction, read its PSP reference from customFields, ask the PSP for the authoritative status, run it through decide_transaction_repair, and only call the transition action when it is not a dry run. flag_review results are logged for a human, never forced through.
Never patch stateId, and never transition a transaction to paid on local state alone. The PSP is the only source of truth for whether money actually moved. Start with DRY_RUN=true, read the planned decisions, and only let the script write once you trust the list. Keep MIN_AGE_MINUTES generous enough that you are never racing a webhook that is still on its way.
The full code
Here is the complete script in one file for each language. It authenticates, searches for order-transactions still open past the safety window, cross-checks the PSP for each one, decides with the pure function, and calls the transition action, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Repair Shopware 6 order-transactions stuck at open despite the PSP confirming
the payment succeeded, without ever patching stateId directly.
The async payment flow expects exactly one request, either the PSP webhook or the
browser redirect to /payment/finalize-transaction, to consume a given payment token.
When both arrive, the loser can be invalidated or short-circuited, so the paid
transition is never called and the transaction is left at open.
Searches order-transaction for anything still open, cross-checks each one's PSP
reference against the PSP's own API as the source of truth, and only when the PSP
is unambiguously paid and the local record is old enough to rule out a race still
in flight does it call the state machine transition action. Everything else is
flagged for a human. Run on a schedule, gated by DRY_RUN. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("fix_stuck_transaction")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
MIN_AGE_MINUTES = float(os.environ.get("MIN_AGE_MINUTES", "15"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def _parse(iso):
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def minutes_between(later_iso, earlier_iso):
return (_parse(later_iso) - _parse(earlier_iso)).total_seconds() / 60
def decide_transaction_repair(local_state, psp_status, now, min_age_minutes=15):
"""Pure decision. No I/O. Takes pre-fetched state snapshots and returns a decision."""
if local_state["technicalName"] != "open":
return {"action": "no_action", "reason": "already resolved"}
if minutes_between(now, local_state["updatedAt"]) < min_age_minutes:
return {"action": "no_action", "reason": "too fresh, let webhook race resolve naturally"}
if psp_status["isPaid"]:
return {"action": "transition_paid", "reason": "PSP confirms paid, local stuck open"}
if psp_status["isFailed"]:
return {"action": "transition_cancel", "reason": "PSP confirms failed/cancelled, local stuck open"}
return {"action": "flag_review", "reason": "PSP status inconclusive, needs human check"}
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_open_transactions(token, limit=100):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "open"}],
"associations": {"order": {}, "stateMachineState": {}},
"sort": [{"field": "createdAt", "order": "ASC"}],
"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 run_transition(token, order_transaction_id, transition):
return api_post(f"/api/_action/order_transaction/{order_transaction_id}/state/{transition}", token, body={})
def get_transaction(token, order_transaction_id):
r = requests.get(
f"{SHOPWARE_URL}/api/order-transaction/{order_transaction_id}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def fetch_psp_status(psp_reference):
"""Out of band call to the PSP's own REST API for the authoritative status.
Swap this for your PSP's SDK or REST call (Mollie, Stripe, etc). Kept separate
from the pure decision function so tests never need network access.
"""
raise NotImplementedError("Wire this to your PSP's transaction status API")
def run():
token = get_token()
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
candidates = search_open_transactions(token)
fixed = 0
flagged = 0
for tx in candidates:
order = tx.get("order") or {}
order_number = order.get("orderNumber", tx.get("id"))
custom_fields = tx.get("customFields") or {}
psp_reference = (
custom_fields.get("mollie_payment_id")
or custom_fields.get("stripe_payment_intent_id")
or custom_fields.get("psp_reference")
)
if not psp_reference:
log.warning("Order %s has no PSP reference in customFields, flagging for review", order_number)
flagged += 1
continue
psp_status = fetch_psp_status(psp_reference)
local_state = {
"technicalName": (tx.get("stateMachineState") or {}).get("technicalName", "open"),
"updatedAt": tx.get("updatedAt") or tx.get("createdAt"),
}
decision = decide_transaction_repair(local_state, psp_status, now, MIN_AGE_MINUTES)
if decision["action"] == "no_action":
continue
if decision["action"] == "flag_review":
log.warning("Order %s: %s", order_number, decision["reason"])
flagged += 1
continue
transition = "paid" if decision["action"] == "transition_paid" else "cancel"
log.info("Order %s: %s. %s '%s'", order_number, decision["reason"],
"would run transition" if DRY_RUN else "running transition", transition)
if not DRY_RUN:
run_transition(token, tx["id"], transition)
refreshed = get_transaction(token, tx["id"])
new_state = (refreshed.get("stateMachineState") or {}).get("technicalName")
log.info("Order %s now %s", order_number, new_state)
fixed += 1
log.info("Done. %d transaction(s) %s, %d flagged for manual review.",
fixed, "to transition" if DRY_RUN else "transitioned", flagged)
if __name__ == "__main__":
run()
/**
* Repair Shopware 6 order-transactions stuck at open despite the PSP confirming
* the payment succeeded, without ever patching stateId directly.
*
* The async payment flow expects exactly one request, either the PSP webhook or the
* browser redirect to /payment/finalize-transaction, to consume a given payment token.
* When both arrive, the loser can be invalidated or short-circuited, so the paid
* transition is never called and the transaction is left at open.
*
* Searches order-transaction for anything still open, cross-checks each one's PSP
* reference against the PSP's own API as the source of truth, and only when the PSP
* is unambiguously paid and the local record is old enough to rule out a race still
* in flight does it call the state machine transition action. Everything else is
* flagged for a human. Run on a schedule, gated by DRY_RUN. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-transaction-stuck-open-payment-race/
*/
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 MIN_AGE_MINUTES = Number(process.env.MIN_AGE_MINUTES || 15);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function minutesBetween(laterIso, earlierIso) {
return (Date.parse(laterIso) - Date.parse(earlierIso)) / 60000;
}
export function decideTransactionRepair(localState, pspStatus, now, minAgeMinutes = 15) {
if (localState.technicalName !== "open") {
return { action: "no_action", reason: "already resolved" };
}
if (minutesBetween(now, localState.updatedAt) < minAgeMinutes) {
return { action: "no_action", reason: "too fresh, let webhook race resolve naturally" };
}
if (pspStatus.isPaid) {
return { action: "transition_paid", reason: "PSP confirms paid, local stuck open" };
}
if (pspStatus.isFailed) {
return { action: "transition_cancel", reason: "PSP confirms failed/cancelled, local stuck open" };
}
return { action: "flag_review", reason: "PSP status inconclusive, needs human check" };
}
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 searchOpenTransactions(token, limit = 100) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "open" }],
associations: { order: {}, stateMachineState: {} },
sort: [{ field: "createdAt", order: "ASC" }],
"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 runTransition(token, orderTransactionId, transition) {
return apiPost(`/api/_action/order_transaction/${orderTransactionId}/state/${transition}`, token, {});
}
async function getTransaction(token, orderTransactionId) {
const res = await fetch(`${SHOPWARE_URL}/api/order-transaction/${orderTransactionId}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Shopware ${res.status} fetching order-transaction`);
const data = await res.json();
return data.data;
}
async function fetchPspStatus(pspReference) {
// Out of band call to the PSP's own REST API for the authoritative status.
// Swap this for your PSP's SDK or REST call (Mollie, Stripe, etc). Kept separate
// from the pure decision function so tests never need network access.
throw new Error("Wire this to your PSP's transaction status API");
}
export async function run() {
const token = await getToken();
const now = new Date().toISOString();
const candidates = await searchOpenTransactions(token);
let fixed = 0;
let flagged = 0;
for (const tx of candidates) {
const order = tx.order || {};
const orderNumber = order.orderNumber || tx.id;
const customFields = tx.customFields || {};
const pspReference =
customFields.mollie_payment_id || customFields.stripe_payment_intent_id || customFields.psp_reference;
if (!pspReference) {
console.warn(`Order ${orderNumber} has no PSP reference in customFields, flagging for review`);
flagged++;
continue;
}
const pspStatus = await fetchPspStatus(pspReference);
const localState = {
technicalName: tx.stateMachineState?.technicalName || "open",
updatedAt: tx.updatedAt || tx.createdAt,
};
const decision = decideTransactionRepair(localState, pspStatus, now, MIN_AGE_MINUTES);
if (decision.action === "no_action") continue;
if (decision.action === "flag_review") {
console.warn(`Order ${orderNumber}: ${decision.reason}`);
flagged++;
continue;
}
const transition = decision.action === "transition_paid" ? "paid" : "cancel";
console.log(`Order ${orderNumber}: ${decision.reason}. ${DRY_RUN ? "would run transition" : "running transition"} '${transition}'`);
if (!DRY_RUN) {
await runTransition(token, tx.id, transition);
const refreshed = await getTransaction(token, tx.id);
console.log(`Order ${orderNumber} now ${refreshed.stateMachineState?.technicalName}`);
}
fixed++;
}
console.log(`Done. ${fixed} transaction(s) ${DRY_RUN ? "to transition" : "transitioned"}, ${flagged} flagged for manual review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a stuck order gets quietly moved to paid. Because decide_transaction_repair is pure and takes plain snapshots and a fixed clock, the test needs no network and no Shopware store, and no live PSP account.
from fix_stuck_transaction import decide_transaction_repair
NOW = "2026-07-10T12:00:00Z"
def local_state(**over):
base = {"technicalName": "open", "updatedAt": "2026-07-10T11:00:00Z"} # 60 minutes old
base.update(over)
return base
def psp(**over):
base = {"isPaid": False, "isFailed": False, "checkedAt": NOW}
base.update(over)
return base
def test_no_action_when_already_resolved():
result = decide_transaction_repair(local_state(technicalName="paid"), psp(isPaid=True), NOW)
assert result == {"action": "no_action", "reason": "already resolved"}
def test_no_action_when_too_fresh():
fresh = local_state(updatedAt="2026-07-10T11:55:00Z") # only 5 minutes old
result = decide_transaction_repair(fresh, psp(isPaid=True), NOW, min_age_minutes=15)
assert result == {"action": "no_action", "reason": "too fresh, let webhook race resolve naturally"}
def test_transitions_paid_when_psp_confirms_paid():
result = decide_transaction_repair(local_state(), psp(isPaid=True), NOW)
assert result == {"action": "transition_paid", "reason": "PSP confirms paid, local stuck open"}
def test_transitions_cancel_when_psp_confirms_failed():
result = decide_transaction_repair(local_state(), psp(isFailed=True), NOW)
assert result == {"action": "transition_cancel", "reason": "PSP confirms failed/cancelled, local stuck open"}
def test_flags_review_when_psp_inconclusive():
result = decide_transaction_repair(local_state(), psp(), NOW)
assert result == {"action": "flag_review", "reason": "PSP status inconclusive, needs human check"}
def test_exactly_at_min_age_is_acted_on():
exactly = local_state(updatedAt="2026-07-10T11:45:00Z") # exactly 15 minutes old
result = decide_transaction_repair(exactly, psp(isPaid=True), NOW, min_age_minutes=15)
assert result["action"] == "transition_paid"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideTransactionRepair } from "./fix-stuck-transaction.js";
const NOW = "2026-07-10T12:00:00Z";
const localState = (over = {}) => ({ technicalName: "open", updatedAt: "2026-07-10T11:00:00Z", ...over });
const psp = (over = {}) => ({ isPaid: false, isFailed: false, checkedAt: NOW, ...over });
test("no_action when already resolved", () => {
const result = decideTransactionRepair(localState({ technicalName: "paid" }), psp({ isPaid: true }), NOW);
assert.deepEqual(result, { action: "no_action", reason: "already resolved" });
});
test("no_action when too fresh", () => {
const fresh = localState({ updatedAt: "2026-07-10T11:55:00Z" });
const result = decideTransactionRepair(fresh, psp({ isPaid: true }), NOW, 15);
assert.deepEqual(result, { action: "no_action", reason: "too fresh, let webhook race resolve naturally" });
});
test("transitions paid when PSP confirms paid", () => {
const result = decideTransactionRepair(localState(), psp({ isPaid: true }), NOW);
assert.deepEqual(result, { action: "transition_paid", reason: "PSP confirms paid, local stuck open" });
});
test("transitions cancel when PSP confirms failed", () => {
const result = decideTransactionRepair(localState(), psp({ isFailed: true }), NOW);
assert.deepEqual(result, { action: "transition_cancel", reason: "PSP confirms failed/cancelled, local stuck open" });
});
test("flags review when PSP inconclusive", () => {
const result = decideTransactionRepair(localState(), psp(), NOW);
assert.deepEqual(result, { action: "flag_review", reason: "PSP status inconclusive, needs human check" });
});
test("exactly at min age is acted on", () => {
const exactly = localState({ updatedAt: "2026-07-10T11:45:00Z" });
const result = decideTransactionRepair(exactly, psp({ isPaid: true }), NOW, 15);
assert.equal(result.action, "transition_paid");
});
Case studies
The invalidated token that left dozens of orders open
A home goods store using Mollie noticed a growing list of orders where Mollie's own dashboard showed the payment as paid, but Shopware still had the transaction on open. Support first assumed customers were lying about paying, until someone matched the Mollie payment ids against the dashboard and found every single one had actually settled.
The pattern lined up with customers on mobile banking apps that reopened the browser after paying, firing a second request against a token the webhook had already consumed. Running the reconciler in dry run surfaced the exact list with each PSP status attached, and turning off dry run cleared the backlog in one pass, with every transition going through _action/order_transaction instead of a raw write.
The customer who paid twice, on paper
A subscription box service saw a handful of orders a week where the transaction stayed open for hours after checkout, then support tickets started asking why an order that was clearly paid would not ship. Digging into the logs showed customers who hit the back button on the payment provider's confirmation page, reloading the finalize URL a second time after it had already run once.
Rather than trying to make the finalize endpoint idempotent against every possible replay, the team added the reconciler as a safety net. It runs hourly, only ever acts on transactions untouched for at least 15 minutes, and checks the PSP directly before doing anything, so a webhook that is merely running a little slow is never mistaken for one that got lost.
After this runs on a schedule, a lost race between the webhook and the browser redirect stops meaning a permanently stuck order. The reconciler finds transactions that Shopware's own flow left behind, confirms with the PSP that the money genuinely moved, and applies the exact same transition the webhook would have called if it had won. Anything the PSP cannot confirm gets a human's eyes instead of a guess, so the fix never becomes a new way to mark an unpaid order as paid.
FAQ
Why does a Shopware 6 order_transaction stay open when the PSP says the payment succeeded?
Shopware's async payment flow expects exactly one request to consume the payment token, either the PSP's webhook callback or the customer's browser redirect to /payment/finalize-transaction, which calls PaymentProcessor::finalize(). When both arrive, whichever one loses the race can hit an invalidated token or a short-circuited transition, so the state machine never receives the call that would move order_transaction from open to paid, even though the PSP settled the charge.
Is it safe to move a stuck order_transaction to paid with a script?
Yes, when the script only acts after cross-checking the PSP's own API for the authoritative status, requires the local state to still be open past a safety window of about 15 minutes so it never races an in-flight webhook, and calls the state machine transition action instead of writing stateId directly. Anything the PSP status cannot confirm should be flagged for a human, not auto-transitioned.
Why can I not just PATCH order_transaction.stateId to paid?
Shopware write protects stateId for the crud scope on every state machine backed entity, so a direct PATCH is rejected. The only supported way to move order_transaction from open to paid is the transition action, POST /api/_action/order_transaction/{id}/state/paid, which runs through the StateMachineRegistry and applies the same logic a webhook or the Administration UI would.
Related field notes
Citations
On the problem:
- The provided token is invalidated and the payment could not be processed (mollie/Shopware6 #1176). github.com/mollie/Shopware6/issues/1176
- Multiple paid transactions per order, despite not being paid in Mollie (mollie/Shopware6 #270). github.com/mollie/Shopware6/issues/270
- Shopware Developer Documentation: Payments (Checkout Concept). developer.shopware.com/docs/concepts/commerce/checkout-concept/payments.html
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
- Shopware Developer Documentation: Payment Flow ADR. developer.shopware.com/docs/resources/references/adr/2021-10-01-payment-flow.html
- Shopware Developer Documentation: Add Payment Plugin. developer.shopware.com/docs/guides/plugins/plugins/checkout/payment/add-payment-plugin.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 a stuck transaction?
If this saved you a wall of support tickets or an order that would not budge, 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