Reconciler Order State Machine
Admin and API show a stale transaction state on multi-transaction orders
A customer's card gets declined, they retry with a different payment method, and the second attempt succeeds. Or a shop switches payment method after a failed attempt. Or a partial payment gets recorded alongside the rest. Whatever the path, the order now has more than one order_transaction row, and Shopware 6 has a habit of showing you the wrong one. The order list, the order detail page, even the order history card can all report a payment status that is out of date, while the truth sits quietly in a transaction the admin never looked at. Here is why that happens and a small script that finds these orders and reports the real state for a human to check.
When an order accumulates more than one order_transaction record, Shopware 6's admin resolves the displayed payment status by iterating the order's transactions and picking the first one that is not cancelled or failed, rather than the chronologically newest one. An old open or partially paid transaction can therefore mask a later paid or refunded transaction, and any API consumer reading order.transactions without explicitly sorting by createdAt hits the same stale view. This is a confirmed, still-open low-priority Shopware core bug, tracked as shopware/shopware#3280. Run a small Python or Node.js script that searches orders with their transactions and state machine states, flags any order with more than one transaction where the true-latest state disagrees with the state the admin's own selection logic would show, and reports each mismatch for a human to verify against the payment provider. Full code, tests, and sources are below.
The problem in plain words
Most orders in Shopware 6 have exactly one transaction. It gets created when the order is placed, it moves through the state machine as the payment is processed, and whatever state it lands on is the order's payment status. There is nothing to pick between, so nothing can go wrong.
The trouble starts the moment a second transaction shows up on the same order. That happens more than people expect: a customer cancels a payment attempt and retries with a different method, a shop switches the payment method after a failed attempt, or a partial or split payment gets recorded as its own row. Each of those is a completely ordinary, supported situation. But when the admin needs to answer the question "what is this order's payment status," it does not sort the transactions by when they were created and take the newest. It walks through them in the order they were received and returns the first one that is not cancelled and not failed. If that first surviving transaction happens to be an old open one, and a later transaction on the same order already moved to paid or refunded, the admin shows you the old one. The correct state is sitting right there in the order's history, just not in the field anyone is looking at.
Why it happens
This is a display and read-path defect, not a corrupted row. A few things make it easy to hit and easy to miss:
- A customer's payment fails, or they cancel and switch payment method at checkout, and Shopware creates a new
order_transactionrather than mutating the old one, which is the correct behavior on its own. - A shop changes the payment method on an order after a failed attempt, or a partial or split payment is recorded as its own transaction row, both ordinary and supported flows that also leave more than one transaction behind.
- The admin's own logic for choosing which transaction to show does not sort by
createdAt. It walks the transactions in the order they were received and stops at the first one that is notcancelledorfailed, so an older surviving transaction wins over a newer one, confirmed and still open as shopware/shopware#3280. - The same wrong-transaction pattern shows up from the payment-provider side too. A webhook can retry into the wrong transaction and leave more than one transaction marked paid when the order was never actually paid in the provider's own records, as reported in mollie/Shopware6#270, and the storefront order history view has its own related report of the wrong state surfacing, tracked as shopware/shopware#1786.
- Any API consumer that reads
order.transactionswithout explicitly sorting bycreatedAtreproduces the exact same mistake the admin makes, since the association does not come back pre-sorted by time.
Every order_transaction row and its stateMachineState are correct on their own. What is wrong is only the question "which transaction should represent this order," and the admin answers it with the wrong tiebreaker. There is no orderTransactionState field on the order entity to patch, because the displayed payment status is derived at read time from the transactions association. So the fix is never a blind write. It is detecting the mismatch, reporting it with enough detail for a human or a support workflow to verify against the payment provider, and only transitioning a specific transaction's state if an investigation confirms it is genuinely wrong.
The fix, as a flow
We never PATCH stateId. The script authenticates, searches order with its transactions association and each transaction's stateMachineState, and keeps only orders with more than one transaction. For each of those, a pure function sorts a copy of the transactions by createdAt descending and returns the true latest one, which we compare against what the admin's own first-non-cancelled-non-failed loop would show. A mismatch gets emitted as a report row with both states side by side, so a human can decide, never an automatic write.
Build it step by step
Get Admin API credentials
Create an Integration in your Shopware Administration under Settings, System, Integrations. Copy the access key id and secret access key. The script exchanges these for a bearer token at POST /api/oauth/token with a client_credentials grant, so keep them in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" # start safe, change to false only for a confirmed transition
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="SWIAxxx"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export DRY_RUN="true" // start safe, change to false only for a confirmed transition
Authenticate and talk to the Admin API
A small helper trades the client id and secret for an access token, then sends it as Authorization: Bearer <token> on every call. We reuse this helper for the search and for any state transition.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Search orders with their transactions and state machine states
Fetch every order with its transactions association, and the stateMachineState association on each transaction, so we get technicalName without a second call. We page through with page and limit, and read total-count-mode:1 so we know when to stop.
def search_orders_with_transactions(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"associations": {"transactions": {"associations": {"stateMachineState": {}}}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
async function searchOrdersWithTransactions(token, page = 1, limit = 500) {
const body = {
page,
limit,
associations: { transactions: { associations: { stateMachineState: {} } } },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
Resolve the true latest transaction, with one pure function
Keep the decision out of the network code entirely. Given a plain array of transactions, each with an id, a createdAt, and a stateMachineState.technicalName, this function sorts a copy by createdAt descending, ties broken by array index, and returns the id and technicalName of the newest one. It deliberately does not skip cancelled or failed states, because the true latest transaction is the true latest transaction no matter what state it landed on. This is the correct algorithm the admin's own loop should have used.
def resolve_latest_transaction_state(transactions):
if not transactions:
return None
indexed = list(enumerate(transactions))
indexed.sort(key=lambda pair: (pair[1].get("createdAt") or "", pair[0]), reverse=True)
latest = indexed[0][1]
return {
"latestId": latest.get("id"),
"technicalName": (latest.get("stateMachineState") or {}).get("technicalName"),
}
export function resolveLatestTransactionState(transactions) {
if (!transactions || transactions.length === 0) return null;
const indexed = transactions.map((t, i) => [i, t]);
indexed.sort((a, b) => {
const byDate = (b[1].createdAt || "").localeCompare(a[1].createdAt || "");
if (byDate !== 0) return byDate;
return b[0] - a[0];
});
const latest = indexed[0][1];
return {
latestId: latest.id,
technicalName: latest.stateMachineState?.technicalName,
};
}
Mirror the admin's own selection to find the stale value
To confirm the mismatch, we also compute what the admin would show: the first transaction, in received order, that is not cancelled and not failed. Comparing that against the true latest state from step 4 is what tells us whether this order is actually affected, rather than just having more than one transaction that all happen to agree.
SKIPPED_STATES = {"cancelled", "failed"}
def admin_displayed_state(transactions):
for tx in transactions:
name = (tx.get("stateMachineState") or {}).get("technicalName")
if name not in SKIPPED_STATES:
return name
return None
const SKIPPED_STATES = new Set(["cancelled", "failed"]);
export function adminDisplayedState(transactions) {
for (const tx of transactions) {
const name = tx.stateMachineState?.technicalName;
if (!SKIPPED_STATES.has(name)) return name;
}
return null;
}
Report the mismatch, never write blindly
For every order with more than one transaction where the admin's displayed state disagrees with the true latest state, emit a report row with both values plus the latest transaction's id and createdAt, so a human or support workflow can verify against the payment provider. Only if that investigation confirms a specific transaction genuinely has the wrong state does a state transition belong here, and only via POST /api/_action/order_transaction/{id}/state/{transition}, guarded by DRY_RUN and explicit confirmation, never a raw PATCH of stateId.
Always start with DRY_RUN=true. This script's job is to report, not to fix. If a transition genuinely needs to happen, run it as its own explicit, human-confirmed step against a specific transaction id, and never against every order the report surfaces.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs every mismatch it finds, respects the dry run flag on the one transition helper it exposes, and never writes anything unless that helper is called explicitly with a specific transaction id and transition.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 orders where the admin shows a stale transaction state, and report them, safely.
When an order accumulates more than one order_transaction, for example after a payment
retry with a different method, Shopware 6's admin resolves the displayed payment status
by picking the first transaction that is not cancelled or failed, in received order,
rather than the chronologically newest one. An old open or partially paid transaction
can therefore mask a later paid or refunded one. This is a confirmed, still-open core
bug (shopware/shopware#3280), a read-path defect, not corrupted data. This script finds
affected orders and reports the true latest state next to the stale one for a human to
verify against the payment provider. It never writes anything unless the transition
helper is called explicitly, with a specific transaction id, behind DRY_RUN.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("report_stale_transaction_state")
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"
SKIPPED_STATES = {"cancelled", "failed"}
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
headers={"Accept": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api(method, path, token, json_body=None):
r = requests.request(
method,
f"{SHOPWARE_URL}{path}",
json=json_body,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=30,
)
r.raise_for_status()
return r.json() if r.text else {}
def search_orders_with_transactions(token, page=1, limit=500):
body = {
"page": page,
"limit": limit,
"associations": {"transactions": {"associations": {"stateMachineState": {}}}},
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def resolve_latest_transaction_state(transactions):
if not transactions:
return None
indexed = list(enumerate(transactions))
indexed.sort(key=lambda pair: (pair[1].get("createdAt") or "", pair[0]), reverse=True)
latest = indexed[0][1]
return {
"latestId": latest.get("id"),
"technicalName": (latest.get("stateMachineState") or {}).get("technicalName"),
}
def admin_displayed_state(transactions):
for tx in transactions:
name = (tx.get("stateMachineState") or {}).get("technicalName")
if name not in SKIPPED_STATES:
return name
return None
def transition_transaction(token, transaction_id, transition):
"""Human-confirmed corrective action only. Never called automatically by run()."""
if DRY_RUN:
log.warning("DRY RUN: would POST /api/_action/order_transaction/%s/state/%s", transaction_id, transition)
return
api("POST", f"/api/_action/order_transaction/{transaction_id}/state/{transition}", token, {})
def run():
token = get_token()
checked = 0
flagged = 0
page = 1
while True:
data = search_orders_with_transactions(token, page=page)
rows = data.get("data", [])
if not rows:
break
for row in rows:
transactions = row.get("transactions") or []
checked += 1
if len(transactions) <= 1:
continue
latest = resolve_latest_transaction_state(transactions)
if latest is None:
continue
displayed = admin_displayed_state(transactions)
if latest["technicalName"] == displayed:
continue
flagged += 1
log.warning(
"STALE order=%s id=%s staleTechnicalName=%s latestTransactionId=%s latestTechnicalName=%s",
row.get("orderNumber", "?"), row["id"], displayed, latest["latestId"], latest["technicalName"],
)
if page * 500 >= data.get("total", 0):
break
page += 1
log.info("Done. %d order(s) checked, %d flagged with a stale displayed transaction state.", checked, flagged)
if __name__ == "__main__":
run()
/**
* Find Shopware 6 orders where the admin shows a stale transaction state, and report them, safely.
*
* When an order accumulates more than one order_transaction, for example after a payment
* retry with a different method, Shopware 6's admin resolves the displayed payment status
* by picking the first transaction that is not cancelled or failed, in received order,
* rather than the chronologically newest one. An old open or partially paid transaction
* can therefore mask a later paid or refunded one. This is a confirmed, still-open core
* bug (shopware/shopware#3280), a read-path defect, not corrupted data. This script finds
* affected orders and reports the true latest state next to the stale one for a human to
* verify against the payment provider. It never writes anything unless the transition
* helper is called explicitly, with a specific transaction id, behind DRY_RUN.
* Run on a schedule. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://demo.example.com").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "SWIAxxx";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "secret_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SKIPPED_STATES = new Set(["cancelled", "failed"]);
export function resolveLatestTransactionState(transactions) {
if (!transactions || transactions.length === 0) return null;
const indexed = transactions.map((t, i) => [i, t]);
indexed.sort((a, b) => {
const byDate = (b[1].createdAt || "").localeCompare(a[1].createdAt || "");
if (byDate !== 0) return byDate;
return b[0] - a[0];
});
const latest = indexed[0][1];
return {
latestId: latest.id,
technicalName: latest.stateMachineState?.technicalName,
};
}
export function adminDisplayedState(transactions) {
for (const tx of transactions) {
const name = tx.stateMachineState?.technicalName;
if (!SKIPPED_STATES.has(name)) return name;
}
return null;
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function api(method, path, token, jsonBody) {
const res = await fetch(`${SHOPWARE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: jsonBody ? JSON.stringify(jsonBody) : undefined,
});
if (!res.ok) throw new Error(`Shopware ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function searchOrdersWithTransactions(token, page = 1, limit = 500) {
const body = {
page,
limit,
associations: { transactions: { associations: { stateMachineState: {} } } },
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
// Human-confirmed corrective action only. Never called automatically by run().
async function transitionTransaction(token, transactionId, transition) {
if (DRY_RUN) {
console.warn(`DRY RUN: would POST /api/_action/order_transaction/${transactionId}/state/${transition}`);
return;
}
await api("POST", `/api/_action/order_transaction/${transactionId}/state/${transition}`, token, {});
}
export async function run() {
const token = await getToken();
let checked = 0;
let flagged = 0;
let page = 1;
while (true) {
const data = await searchOrdersWithTransactions(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
for (const row of rows) {
const transactions = row.transactions || [];
checked++;
if (transactions.length <= 1) continue;
const latest = resolveLatestTransactionState(transactions);
if (!latest) continue;
const displayed = adminDisplayedState(transactions);
if (latest.technicalName === displayed) continue;
flagged++;
console.warn(
`STALE order=${row.orderNumber || "?"} id=${row.id} staleTechnicalName=${displayed} latestTransactionId=${latest.latestId} latestTechnicalName=${latest.technicalName}`
);
}
if (page * 500 >= (data.total || 0)) break;
page++;
}
console.log(`Done. ${checked} order(s) checked, ${flagged} flagged with a stale displayed transaction state.`);
}
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 transaction we trust as an order's true state. Because resolve_latest_transaction_state and admin_displayed_state are pure, no network and no Shopware instance is needed. They just take plain objects in and check the answer.
from report_stale_transaction_state import resolve_latest_transaction_state, admin_displayed_state
def tx(id_, created_at, technical_name):
return {"id": id_, "createdAt": created_at, "stateMachineState": {"technicalName": technical_name}}
def test_returns_none_for_empty_list():
assert resolve_latest_transaction_state([]) is None
def test_resolves_the_chronologically_newest_transaction():
txs = [
tx("a", "2026-07-01T00:00:00Z", "open"),
tx("b", "2026-07-05T00:00:00Z", "paid"),
]
assert resolve_latest_transaction_state(txs) == {"latestId": "b", "technicalName": "paid"}
def test_does_not_filter_out_cancelled_or_failed_when_newest():
txs = [
tx("a", "2026-07-01T00:00:00Z", "paid"),
tx("b", "2026-07-05T00:00:00Z", "cancelled"),
]
assert resolve_latest_transaction_state(txs) == {"latestId": "b", "technicalName": "cancelled"}
def test_ties_broken_by_array_index_stable_secondary_key():
txs = [
tx("a", "2026-07-05T00:00:00Z", "open"),
tx("b", "2026-07-05T00:00:00Z", "paid"),
]
assert resolve_latest_transaction_state(txs) == {"latestId": "b", "technicalName": "paid"}
def test_admin_displayed_state_skips_cancelled_and_failed():
txs = [
tx("a", "2026-07-01T00:00:00Z", "cancelled"),
tx("b", "2026-07-02T00:00:00Z", "failed"),
tx("c", "2026-07-03T00:00:00Z", "open"),
]
assert admin_displayed_state(txs) == "open"
def test_admin_displayed_state_returns_none_when_all_skipped():
txs = [
tx("a", "2026-07-01T00:00:00Z", "cancelled"),
tx("b", "2026-07-02T00:00:00Z", "failed"),
]
assert admin_displayed_state(txs) is None
def test_mismatch_detected_when_stale_open_masks_true_paid():
txs = [
tx("a", "2026-07-01T00:00:00Z", "open"),
tx("b", "2026-07-05T00:00:00Z", "paid"),
]
latest = resolve_latest_transaction_state(txs)
displayed = admin_displayed_state(txs)
assert displayed == "open"
assert latest["technicalName"] == "paid"
assert displayed != latest["technicalName"]
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveLatestTransactionState, adminDisplayedState } from "./report-stale-transaction-state.js";
const tx = (id, createdAt, technicalName) => ({ id, createdAt, stateMachineState: { technicalName } });
test("returns null for empty list", () => {
assert.equal(resolveLatestTransactionState([]), null);
});
test("resolves the chronologically newest transaction", () => {
const txs = [tx("a", "2026-07-01T00:00:00Z", "open"), tx("b", "2026-07-05T00:00:00Z", "paid")];
assert.deepEqual(resolveLatestTransactionState(txs), { latestId: "b", technicalName: "paid" });
});
test("does not filter out cancelled or failed when newest", () => {
const txs = [tx("a", "2026-07-01T00:00:00Z", "paid"), tx("b", "2026-07-05T00:00:00Z", "cancelled")];
assert.deepEqual(resolveLatestTransactionState(txs), { latestId: "b", technicalName: "cancelled" });
});
test("ties broken by array index stable secondary key", () => {
const txs = [tx("a", "2026-07-05T00:00:00Z", "open"), tx("b", "2026-07-05T00:00:00Z", "paid")];
assert.deepEqual(resolveLatestTransactionState(txs), { latestId: "b", technicalName: "paid" });
});
test("adminDisplayedState skips cancelled and failed", () => {
const txs = [tx("a", "2026-07-01T00:00:00Z", "cancelled"), tx("b", "2026-07-02T00:00:00Z", "failed"), tx("c", "2026-07-03T00:00:00Z", "open")];
assert.equal(adminDisplayedState(txs), "open");
});
test("adminDisplayedState returns null when all skipped", () => {
const txs = [tx("a", "2026-07-01T00:00:00Z", "cancelled"), tx("b", "2026-07-02T00:00:00Z", "failed")];
assert.equal(adminDisplayedState(txs), null);
});
test("mismatch detected when stale open masks true paid", () => {
const txs = [tx("a", "2026-07-01T00:00:00Z", "open"), tx("b", "2026-07-05T00:00:00Z", "paid")];
const latest = resolveLatestTransactionState(txs);
const displayed = adminDisplayedState(txs);
assert.equal(displayed, "open");
assert.equal(latest.technicalName, "paid");
assert.notEqual(displayed, latest.technicalName);
});
Case studies
A declined card masked a successful retry
A subscription box store had a customer whose card was declined at checkout, so they retried immediately with a different card and the second attempt succeeded. Support kept getting asked "did my order go through," because the order list showed the order still sitting on the payment state from the first, failed attempt, even though the second transaction had already moved to paid.
Running the report script found the order in seconds: two transactions, an old declined one first, a paid one created four minutes later. The report row gave the finance team the exact transaction id to check against the payment provider, confirmed the customer had in fact paid, and closed the confusion without anyone touching stateId directly.
A B2B shop switched payment method mid-order
A wholesale shop let account managers switch a large order from invoice to card payment after the invoice option stalled. That created a second transaction on the order, and the order overview kept showing the stale invoice-pending state well after the card payment cleared, so the warehouse held the shipment thinking payment was still outstanding.
The scheduled report caught the mismatch the same morning, flagged the order with both the stale and the true latest state side by side, and a supervisor cross-checked it against the state-machine history before releasing the shipment, exactly the kind of verification the flag-first approach is built for.
After this runs on a schedule, an order that picked up a second transaction no longer gets to hide its true payment state behind an older one. Every mismatch shows up in a report with the stale state, the true latest state, and the exact transaction id to check against the payment provider, and nothing gets written unless a human reviews that report and explicitly confirms a transition on that specific transaction.
FAQ
Why does my Shopware 6 order show the wrong payment status when it has more than one transaction?
Shopware 6 resolves the order's displayed payment status by iterating its transactions and picking the first one that is not cancelled or failed, rather than the chronologically newest one. If the order has more than one order_transaction, for example after a customer retried payment with a different method, an old open or partially paid transaction can be returned before a later paid or refunded one, so the admin and any API consumer that does not sort by createdAt sees a stale state.
Is this a data corruption bug I need to repair?
No. The underlying order_transaction rows and their stateMachineState are correct, this is a read-path selection defect, tracked as shopware/shopware#3280. There is no separate field on the order entity to correct, since paymentStatus is derived at read time from the transactions association, so the safe response is to detect and report the mismatch, not to blindly write anything.
What is the correct way to find an order's true payment state?
Sort a copy of the order's transactions by createdAt descending and read the stateMachineState.technicalName of the first result, without filtering out any state such as cancelled or failed. You can corroborate that result against the order's state-machine history, which always reflects the true current state regardless of which transaction the admin's own loop happens to pick.
Related field notes
Citations
On the problem:
- GitHub Issue: Admin shows wrong transaction state. github.com/shopware/shopware/issues/3280
- GitHub Issue: Multiple paid transactions per order, despite not being paid in Mollie. github.com/mollie/Shopware6/issues/270
- GitHub Issue: Wrong transactionstate and wrong orderstate in frontend (account/order-history/order-item). github.com/shopware/shopware/issues/1786
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com using-the-state-machine
- Shopware Admin API Reference: Order Management. shopware.stoplight.io admin-api order-management
- Shopware Developer Documentation: Payments, the checkout concept. developer.shopware.com checkout-concept payments
Stuck on a tricky one?
If you have a problem in Shopware order states, payments, 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 stale payment status?
If this saved you a confused support ticket or a held shipment, 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