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.

Python and Node.js Shopware Admin API Safe by default (dry run)
The short answer

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.

Transaction 1 created first, state: open Transaction 2 created after retry, state: paid Admin iterates in received order first non-cancelled, non-failed picks transaction 1, not the newest Displayed status: open stale, masks paid Admin and API The real state, paid, is sitting in transaction 2 and in the state-machine history but nothing points a reader at it by default
The data is correct in both transactions. The selection logic just returns the wrong one when there is more than one to choose from.

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:

The key insight

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.

Search order with transactions association Keep count > 1 more than one transaction resolveLatestTransactionState sort by createdAt desc Latest state differs from admin's pick? yes, report no, skip (states agree) Report row for human review never auto-written
The pure function only decides which state is the true latest one. Reporting a mismatch is the only output, a state transition happens later, and only after a human confirms it against the payment provider.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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 {}
step2.js
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) : {};
}
3

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.

step3.py
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)
step3.js
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);
}
4

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.

decide.py
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"),
    }
decide.js
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,
  };
}
5

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.

step5.py
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
step5.js
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;
}
6

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.

Run it safe

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.

report_stale_transaction_state.py
"""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()
report-stale-transaction-state.js
/**
 * 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.

test_stale_transaction.py
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"]
stale-transaction.test.js
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

Payment retry

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.

Payment method switch

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.

What good looks like

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:

  1. GitHub Issue: Admin shows wrong transaction state. github.com/shopware/shopware/issues/3280
  2. GitHub Issue: Multiple paid transactions per order, despite not being paid in Mollie. github.com/mollie/Shopware6/issues/270
  3. GitHub Issue: Wrong transactionstate and wrong orderstate in frontend (account/order-history/order-item). github.com/shopware/shopware/issues/1786

On the solution:

  1. Shopware Developer Documentation: Using the State Machine. developer.shopware.com using-the-state-machine
  2. Shopware Admin API Reference: Order Management. shopware.stoplight.io admin-api order-management
  3. 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.

Contact me on LinkedIn

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

Back to all Shopware field notes