Diagnostic Order State Machine
Filtering Shopware 6 orders by cancelled payment status returns wrong results
Someone runs a criteria filter for orders whose payment is cancelled, expecting to see the orders that genuinely failed to pay. Instead the list includes orders that are sitting there fully paid, or still waiting to be paid, mixed in with the ones that actually failed. Nothing is broken on the order itself. The filter is asking the wrong question of a collection that remembers every payment attempt an order ever had, not just the one that matters now. Here is why it happens and a small script that finds the false positives and reports them for review.
order.transactions is a to-many association. An order keeps every historical OrderTransaction record, including a first payment attempt that was cancelled followed by a retry that succeeded, and Shopware never overwrites or deletes the old one. A criteria filter such as {type:'equals', field:'transactions.stateMachineState.technicalName', value:'cancelled'} matches the order if any transaction in that collection has technicalName equal to cancelled, not necessarily the current one, so orders whose real payment state is paid or open still appear in a cancelled filter. Run a small Python or Node.js script that repeats the same suspect search, sorts each order's transactions by createdAt to find the real current transaction, and reports every order where the filter matched but the current transaction is not actually cancelled. This is a query fix, not a data fix, so the script only writes a report, never a state transition. Full code, tests, and sources are below.
The problem in plain words
An order in Shopware 6 does not have one payment state stored in one place. It has a collection of OrderTransaction rows, one for each attempt at getting paid. If a customer starts checkout, picks a payment method, and that attempt gets cancelled, Shopware writes a transaction row with stateMachineState.technicalName set to cancelled. If the customer then retries with a different payment method and it succeeds, Shopware adds a second transaction row set to paid. Both rows stay on the order. Nothing is deleted, nothing is overwritten.
That is exactly the right behavior for keeping an honest audit trail of what happened. The trouble starts when a query treats transactions as if it only ever holds one row. The Admin API's search criteria lets you filter directly on transactions.stateMachineState.technicalName, and an equals filter on a to-many association matches the parent order the moment any child row satisfies the condition. So an order with a cancelled first attempt and a paid second attempt matches a filter for cancelled, even though the order today is fully paid.
Why it happens
This comes down to the shape of the data and how the search criteria treats it. A few things make it easy to hit:
order.transactionsis a to-many association by design, so Shopware can preserve the full payment history for an order, including failed and cancelled attempts before a successful retry.- A criteria filter with
equalson a to-many path is an existence check across the collection. It answers "does at least one row match," not "does the current row match." - The Admin API does not guarantee the order transactions come back in, so even a consumer that fetches the full
transactionsassociation and looks at the first entry can pick the wrong one unless it explicitly sorts bycreatedAt. - The exact same to-many mismatch pattern applies to
order.deliveriesand delivery-state filters, so a query built the same way against delivery state can carry the same false positives. - Shopware's own answer to this ambiguity was adding a dedicated
primaryOrderTransactionassociation and field on the Order entity in 6.8.0, specifically because filtering across the fulltransactionscollection is not a reliable way to determine an order's current payment status.
This is not a broken order. The historical OrderTransaction rows are correct, the first attempt really was cancelled. The mismatch lives entirely in the query, which asks whether any transaction in the collection is cancelled instead of asking what the order's current transaction state is. That means the fix is to change how you read the state, on primaryOrderTransaction.stateMachineState.technicalName where available, or by sorting transactions by createdAt and taking the last one everywhere else, and never to force a transition on the old transaction record.
The fix, as a flow
We do not touch any transaction, order, or delivery state here. The script repeats the same suspect filter that consumers already run, fetches the full transactions association per order, picks the transaction with the newest createdAt as the current one, and flags any order where the filter matched cancelled but the current transaction's state is something else.
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, this script only ever reports
// 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, this script only ever reports
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 suspect search and nothing else, this diagnostic never writes.
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) : {};
}
Repeat the suspect filter, with the transactions association
Run the same search a downstream consumer would run, a filter for transactions.stateMachineState.technicalName equals cancelled, but this time include the full transactions association with its stateMachineState, plus the order's own stateMachineState, since the same to-many mismatch pattern affects order.deliveries too. Page with page and limit, and sort hits by orderNumber for a stable, readable report.
def find_orders_matching_cancelled_filter(token, page=1, limit=100):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals",
"field": "transactions.stateMachineState.technicalName",
"value": "cancelled"}],
"associations": {
"transactions": {"associations": {"stateMachineState": {}}},
"stateMachineState": {},
},
"sort": [{"field": "orderNumber", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
async function findOrdersMatchingCancelledFilter(token, page = 1, limit = 100) {
const body = {
page,
limit,
filter: [{ type: "equals",
field: "transactions.stateMachineState.technicalName",
value: "cancelled" }],
associations: {
transactions: { associations: { stateMachineState: {} } },
stateMachineState: {},
},
sort: [{ field: "orderNumber", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
Pick the current transaction, with one pure function
The Admin API does not guarantee association order, so sort the order's transactions by createdAt yourself and take the newest one as the current transaction. Ties, which should not normally happen, fall back to the highest array index, mirroring insertion order. An empty array returns nothing to pick from.
def pick_current_transaction_state(transactions):
if not transactions:
return None
best_index = 0
for i in range(1, len(transactions)):
if transactions[i]["createdAt"] >= transactions[best_index]["createdAt"]:
best_index = i
current = transactions[best_index]
return {"id": current["id"], "technicalName": current["stateMachineState"]["technicalName"]}
def is_false_positive_cancelled_match(transactions, filter_value="cancelled"):
any_matches_filter = any(
t["stateMachineState"]["technicalName"] == filter_value for t in transactions
)
if not any_matches_filter:
return False
current = pick_current_transaction_state(transactions)
return current is not None and current["technicalName"] != filter_value
export function pickCurrentTransactionState(transactions) {
if (!transactions || transactions.length === 0) return null;
let bestIndex = 0;
for (let i = 1; i < transactions.length; i++) {
if (transactions[i].createdAt >= transactions[bestIndex].createdAt) bestIndex = i;
}
const current = transactions[bestIndex];
return { id: current.id, technicalName: current.stateMachineState.technicalName };
}
export function isFalsePositiveCancelledMatch(transactions, filterValue = "cancelled") {
const anyMatchesFilter = transactions.some(
(t) => t.stateMachineState.technicalName === filterValue
);
if (!anyMatchesFilter) return false;
const current = pickCurrentTransactionState(transactions);
return current !== null && current.technicalName !== filterValue;
}
Build the report row, do not transition anything
For every order where is_false_positive_cancelled_match is true, emit a plain report row: orderId, orderNumber, matchedBy, the stale transaction's id and state, and the current transaction's id and state. There is no POST /api/_action/order_transaction/{id}/state/{transition} call anywhere in this script. Forcing a transition on an old, already-terminal transaction record would corrupt real payment history, so the only output here is data for a human to read.
def build_report_row(order):
transactions = order.get("transactions") or []
stale = next(
(t for t in transactions if t["stateMachineState"]["technicalName"] == "cancelled"),
None,
)
current = pick_current_transaction_state(transactions)
return {
"orderId": order["id"],
"orderNumber": order.get("orderNumber"),
"matchedBy": "stale transaction in filter",
"staleTransactionId": stale["id"] if stale else None,
"staleTransactionState": "cancelled",
"currentTransactionId": current["id"] if current else None,
"currentTransactionState": current["technicalName"] if current else None,
}
export function buildReportRow(order) {
const transactions = order.transactions || [];
const stale = transactions.find((t) => t.stateMachineState.technicalName === "cancelled");
const current = pickCurrentTransactionState(transactions);
return {
orderId: order.id,
orderNumber: order.orderNumber,
matchedBy: "stale transaction in filter",
staleTransactionId: stale ? stale.id : null,
staleTransactionState: "cancelled",
currentTransactionId: current ? current.id : null,
currentTransactionState: current ? current.technicalName : null,
};
}
Wire it together behind a DRY_RUN guard
Even though this script never writes to Shopware, keep the same DRY_RUN convention as every other job in this series, defaulted to true. It only ever lists results here, but the guard keeps the pattern consistent if this script is ever extended, and it makes the intent explicit to the next person who reads it: nothing here transitions state, ever.
This is a report, not a repair. Do not wire a transition call onto its output. The right fix is upstream: change the consumers of this filter to read primaryOrderTransaction.stateMachineState.technicalName on Shopware 6.8 and later, or to sort transactions by createdAt descending and take the first result before comparing state on older versions.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and never calls a state transition endpoint, because there is nothing here that is safe to auto-write.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Find Shopware 6 orders that a cancelled-payment filter matches by mistake, and report them.
order.transactions is a to-many association. An order keeps every historical
OrderTransaction record, including a first payment attempt that was cancelled followed
by a retry that succeeded, and Shopware never overwrites or deletes the old row. A
criteria filter such as equals on transactions.stateMachineState.technicalName matches
the order if ANY transaction in that collection is cancelled, not necessarily the
current one, so orders whose real payment state is paid or open still show up in a
cancelled filter. This is a query and data interpretation mismatch, not a broken order,
so this script only ever reports the false positives. It never calls a state transition
endpoint. Run on a schedule or on demand. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("report_cancelled_filter_mismatch")
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"
FILTER_VALUE = os.environ.get("FILTER_VALUE", "cancelled")
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 find_orders_matching_cancelled_filter(token, page=1, limit=100):
body = {
"page": page,
"limit": limit,
"filter": [{"type": "equals",
"field": "transactions.stateMachineState.technicalName",
"value": FILTER_VALUE}],
"associations": {
"transactions": {"associations": {"stateMachineState": {}}},
"stateMachineState": {},
},
"sort": [{"field": "orderNumber", "order": "ASC"}],
"total-count-mode": 1,
}
return api("POST", "/api/search/order", token, body)
def pick_current_transaction_state(transactions):
if not transactions:
return None
best_index = 0
for i in range(1, len(transactions)):
if transactions[i]["createdAt"] >= transactions[best_index]["createdAt"]:
best_index = i
current = transactions[best_index]
return {"id": current["id"], "technicalName": current["stateMachineState"]["technicalName"]}
def is_false_positive_cancelled_match(transactions, filter_value=FILTER_VALUE):
any_matches_filter = any(
t["stateMachineState"]["technicalName"] == filter_value for t in transactions
)
if not any_matches_filter:
return False
current = pick_current_transaction_state(transactions)
return current is not None and current["technicalName"] != filter_value
def build_report_row(order):
transactions = order.get("transactions") or []
stale = next(
(t for t in transactions if t["stateMachineState"]["technicalName"] == FILTER_VALUE),
None,
)
current = pick_current_transaction_state(transactions)
return {
"orderId": order["id"],
"orderNumber": order.get("orderNumber"),
"matchedBy": "stale transaction in filter",
"staleTransactionId": stale["id"] if stale else None,
"staleTransactionState": FILTER_VALUE,
"currentTransactionId": current["id"] if current else None,
"currentTransactionState": current["technicalName"] if current else None,
}
def run():
token = get_token()
flagged = []
page = 1
while True:
data = find_orders_matching_cancelled_filter(token, page=page)
rows = data.get("data", [])
if not rows:
break
for order in rows:
transactions = order.get("transactions") or []
if is_false_positive_cancelled_match(transactions, FILTER_VALUE):
row = build_report_row(order)
flagged.append(row)
log.warning(
"Order %s matched '%s' filter but current transaction is '%s'. %s",
row["orderNumber"], FILTER_VALUE, row["currentTransactionState"],
"dry run, report only" if DRY_RUN else "report only, no write issued",
)
if page * 100 >= data.get("total", 0):
break
page += 1
log.info("Done. %d order(s) flagged as false positives out of the '%s' filter.",
len(flagged), FILTER_VALUE)
print(json.dumps(flagged, indent=2))
return flagged
if __name__ == "__main__":
run()
/**
* Find Shopware 6 orders that a cancelled-payment filter matches by mistake, and report them.
*
* order.transactions is a to-many association. An order keeps every historical
* OrderTransaction record, including a first payment attempt that was cancelled followed
* by a retry that succeeded, and Shopware never overwrites or deletes the old row. A
* criteria filter such as equals on transactions.stateMachineState.technicalName matches
* the order if ANY transaction in that collection is cancelled, not necessarily the
* current one, so orders whose real payment state is paid or open still show up in a
* cancelled filter. This is a query and data interpretation mismatch, not a broken order,
* so this script only ever reports the false positives. It never calls a state transition
* endpoint. Run on a schedule or on demand. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-filter-cancelled-mismatch/
*/
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 FILTER_VALUE = process.env.FILTER_VALUE || "cancelled";
export function pickCurrentTransactionState(transactions) {
if (!transactions || transactions.length === 0) return null;
let bestIndex = 0;
for (let i = 1; i < transactions.length; i++) {
if (transactions[i].createdAt >= transactions[bestIndex].createdAt) bestIndex = i;
}
const current = transactions[bestIndex];
return { id: current.id, technicalName: current.stateMachineState.technicalName };
}
export function isFalsePositiveCancelledMatch(transactions, filterValue = FILTER_VALUE) {
const anyMatchesFilter = transactions.some(
(t) => t.stateMachineState.technicalName === filterValue
);
if (!anyMatchesFilter) return false;
const current = pickCurrentTransactionState(transactions);
return current !== null && current.technicalName !== filterValue;
}
export function buildReportRow(order) {
const transactions = order.transactions || [];
const stale = transactions.find((t) => t.stateMachineState.technicalName === FILTER_VALUE);
const current = pickCurrentTransactionState(transactions);
return {
orderId: order.id,
orderNumber: order.orderNumber,
matchedBy: "stale transaction in filter",
staleTransactionId: stale ? stale.id : null,
staleTransactionState: FILTER_VALUE,
currentTransactionId: current ? current.id : null,
currentTransactionState: current ? current.technicalName : 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 findOrdersMatchingCancelledFilter(token, page = 1, limit = 100) {
const body = {
page,
limit,
filter: [{ type: "equals",
field: "transactions.stateMachineState.technicalName",
value: FILTER_VALUE }],
associations: {
transactions: { associations: { stateMachineState: {} } },
stateMachineState: {},
},
sort: [{ field: "orderNumber", order: "ASC" }],
"total-count-mode": 1,
};
return api("POST", "/api/search/order", token, body);
}
export async function run() {
const token = await getToken();
const flagged = [];
let page = 1;
while (true) {
const data = await findOrdersMatchingCancelledFilter(token, page);
const rows = data.data || [];
if (rows.length === 0) break;
for (const order of rows) {
const transactions = order.transactions || [];
if (isFalsePositiveCancelledMatch(transactions, FILTER_VALUE)) {
const row = buildReportRow(order);
flagged.push(row);
console.warn(
`Order ${row.orderNumber} matched '${FILTER_VALUE}' filter but current transaction is '${row.currentTransactionState}'. ${DRY_RUN ? "dry run, report only" : "report only, no write issued"}`
);
}
}
if (page * 100 >= (data.total || 0)) break;
page++;
}
console.log(`Done. ${flagged.length} order(s) flagged as false positives out of the '${FILTER_VALUE}' filter.`);
console.log(JSON.stringify(flagged, null, 2));
return flagged;
}
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 orders end up on a report someone will act on. Because pick_current_transaction_state and is_false_positive_cancelled_match are pure, the tests need no network and no Shopware instance. They just feed in plain transaction lists and check the answer, covering one, two, and three or more transactions per order, including transactions whose createdAt values arrive out of order.
from report_cancelled_filter_mismatch import (
pick_current_transaction_state,
is_false_positive_cancelled_match,
)
def txn(id_, created_at, state):
return {"id": id_, "createdAt": created_at, "stateMachineState": {"technicalName": state}}
def test_pick_current_returns_none_for_empty_list():
assert pick_current_transaction_state([]) is None
def test_pick_current_with_single_transaction():
t = [txn("t1", "2026-07-01T00:00:00", "cancelled")]
assert pick_current_transaction_state(t) == {"id": "t1", "technicalName": "cancelled"}
def test_pick_current_with_two_transactions_in_order():
t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "paid"),
]
assert pick_current_transaction_state(t) == {"id": "t2", "technicalName": "paid"}
def test_pick_current_with_out_of_order_created_at():
t = [
txn("t1", "2026-07-05T00:00:00", "paid"),
txn("t2", "2026-07-01T00:00:00", "cancelled"),
]
# t1 has the later createdAt even though it appears first in the array
assert pick_current_transaction_state(t) == {"id": "t1", "technicalName": "paid"}
def test_pick_current_with_three_or_more_picks_newest():
t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-03T00:00:00", "open"),
txn("t3", "2026-07-02T00:00:00", "reminded"),
]
assert pick_current_transaction_state(t) == {"id": "t2", "technicalName": "open"}
def test_tie_breaks_to_highest_index():
t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-01T00:00:00", "paid"),
]
assert pick_current_transaction_state(t) == {"id": "t2", "technicalName": "paid"}
def test_false_positive_when_stale_cancelled_but_current_is_paid():
t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "paid"),
]
assert is_false_positive_cancelled_match(t, "cancelled") is True
def test_not_false_positive_when_current_is_also_cancelled():
t = [
txn("t1", "2026-07-01T00:00:00", "open"),
txn("t2", "2026-07-02T00:00:00", "cancelled"),
]
assert is_false_positive_cancelled_match(t, "cancelled") is False
def test_not_false_positive_when_no_transaction_matches_filter():
t = [
txn("t1", "2026-07-01T00:00:00", "open"),
txn("t2", "2026-07-02T00:00:00", "paid"),
]
assert is_false_positive_cancelled_match(t, "cancelled") is False
def test_false_positive_with_three_transactions_current_is_open():
t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "cancelled"),
txn("t3", "2026-07-03T00:00:00", "open"),
]
assert is_false_positive_cancelled_match(t, "cancelled") is True
def test_empty_transactions_is_not_a_false_positive():
assert is_false_positive_cancelled_match([], "cancelled") is False
import { test } from "node:test";
import assert from "node:assert/strict";
import {
pickCurrentTransactionState,
isFalsePositiveCancelledMatch,
} from "./report-cancelled-filter-mismatch.js";
const txn = (id, createdAt, state) => ({
id,
createdAt,
stateMachineState: { technicalName: state },
});
test("pick current returns null for empty list", () => {
assert.equal(pickCurrentTransactionState([]), null);
});
test("pick current with a single transaction", () => {
const t = [txn("t1", "2026-07-01T00:00:00", "cancelled")];
assert.deepEqual(pickCurrentTransactionState(t), { id: "t1", technicalName: "cancelled" });
});
test("pick current with two transactions in order", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "paid"),
];
assert.deepEqual(pickCurrentTransactionState(t), { id: "t2", technicalName: "paid" });
});
test("pick current with out of order createdAt", () => {
const t = [
txn("t1", "2026-07-05T00:00:00", "paid"),
txn("t2", "2026-07-01T00:00:00", "cancelled"),
];
assert.deepEqual(pickCurrentTransactionState(t), { id: "t1", technicalName: "paid" });
});
test("pick current with three or more picks newest", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-03T00:00:00", "open"),
txn("t3", "2026-07-02T00:00:00", "reminded"),
];
assert.deepEqual(pickCurrentTransactionState(t), { id: "t2", technicalName: "open" });
});
test("tie breaks to highest index", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-01T00:00:00", "paid"),
];
assert.deepEqual(pickCurrentTransactionState(t), { id: "t2", technicalName: "paid" });
});
test("false positive when stale cancelled but current is paid", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "paid"),
];
assert.equal(isFalsePositiveCancelledMatch(t, "cancelled"), true);
});
test("not false positive when current is also cancelled", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "open"),
txn("t2", "2026-07-02T00:00:00", "cancelled"),
];
assert.equal(isFalsePositiveCancelledMatch(t, "cancelled"), false);
});
test("not false positive when no transaction matches filter", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "open"),
txn("t2", "2026-07-02T00:00:00", "paid"),
];
assert.equal(isFalsePositiveCancelledMatch(t, "cancelled"), false);
});
test("false positive with three transactions current is open", () => {
const t = [
txn("t1", "2026-07-01T00:00:00", "cancelled"),
txn("t2", "2026-07-02T00:00:00", "cancelled"),
txn("t3", "2026-07-03T00:00:00", "open"),
];
assert.equal(isFalsePositiveCancelledMatch(t, "cancelled"), true);
});
test("empty transactions is not a false positive", () => {
assert.equal(isFalsePositiveCancelledMatch([], "cancelled"), false);
});
Case studies
A support tool listed paid orders as cancelled
A merchant built an internal dashboard that used the Admin API to list orders whose payment was cancelled, so support agents could follow up on failed checkouts. Agents kept opening orders from that list only to find a shipping label already printed and a card charge that had gone through days earlier on a second attempt.
Running the diagnostic against the same filter showed dozens of orders where the first transaction was cancelled but a later retry had succeeded. The dashboard's own query was rewritten to read primaryOrderTransaction.stateMachineState.technicalName, and the false alarms in the support queue stopped the same day.
A revenue export undercounted paid orders
A finance team exported "cancelled payment" orders every month to reconcile against their payment processor, and the export always undercounted actual cancellations because the same orders also appeared, incorrectly, in a separate "paid" export built the same way, matching on any transaction rather than the current one.
The diagnostic script's report made the double counting visible in one run, without touching a single order. The two exports were rebuilt to sort transactions by createdAt and use only the last one, and the monthly reconciliation numbers finally matched the payment processor's own totals.
After this diagnostic runs, every order that a cancelled-payment filter mismatches gets surfaced with its real current transaction state right next to the stale one that confused the query. No transaction is ever transitioned by this script, so nobody risks corrupting payment history chasing a query bug. The permanent fix is upstream, reading primaryOrderTransaction where available or sorting transactions by createdAt everywhere else, and this report is what proves that fix is worth making.
FAQ
Why does filtering Shopware 6 orders by a cancelled transaction state return orders that are actually paid?
An order in Shopware 6 keeps every historical OrderTransaction record. If a customer's first payment attempt was cancelled and a retry with a different method succeeded, the order still carries both transaction rows. A criteria filter with equals on transactions.stateMachineState.technicalName matches the order if any transaction in that to-many collection has the value cancelled, not just the latest one, so an order whose current payment is paid or open can still show up in a cancelled filter.
Should I fix this by transitioning the old cancelled transaction to a different state?
No. The old OrderTransaction record is historically correct, it really was cancelled, so forcing a state transition on it would corrupt real payment history. This is a query and data interpretation mismatch, not a broken order. The correct fix is to change how you read the current state, not to rewrite the historical transaction.
How do I reliably find an order's current payment state instead of matching any historical transaction?
On Shopware 6.8 and later, read primaryOrderTransaction.stateMachineState.technicalName, a dedicated field Shopware added specifically because filtering across the full transactions to-many collection is unreliable. On older versions, fetch the transactions association, sort by createdAt descending, and take the first result per order as the current transaction before comparing state, instead of filtering directly on the to-many path.
Related field notes
Citations
On the problem:
- Shopware GitHub Issues: Filter orders based on status. github.com/shopware/shopware/issues/294
- Shopware GitHub Issues: v6.6 stateMachineState for delivery and payment is always null. github.com/shopware/shopware/issues/3717
- Shopware Community Forum: Orders nach transaction- und delivery-state filtern über DAL. forum.shopware.com orders-nach-transaction-und-delivery-state-filtern-uber-dal
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com using-the-state-machine
- Shopware Developer Documentation: Search Criteria. developer.shopware.com search-criteria
- Shopware Admin API Reference: Order Management. shopware.stoplight.io admin-api order-management
Stuck on a tricky one?
If you have a problem in Shopware order states, stock, message queue, or data integrity 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 untangle a bad filter?
If this saved you from chasing orders that were never really cancelled, 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