Diagnostic Order State Machine

Flow Builder applies a payment status change to the wrong transaction

A payment gets retried, a customer switches payment method mid-checkout, or an order is split into partial payments, and the order ends up with more than one order_transaction row. A Flow Builder flow fires its "Change payment status" action and blows up with an IllegalTransitionException, or worse, silently transitions the wrong one. Here is why Shopware cannot always tell which transaction the flow meant, and a small script that detects the mismatch and reports the correct transaction id for review before anyone forces a change.

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

An order can carry more than one order_transaction when a payment attempt was retried, the customer changed payment method, or the order has a partial-payment split. Shopware exposes those rows as an unordered or insertion-ordered collection, and Flow Builder's "Change payment status" action commonly resolves the transaction to transition from the first or last item of that collection instead of the specific id tied to the triggering event. On a multi-transaction order it can grab a stale transaction that is not in a compatible state, so the state machine rejects the call with an IllegalTransitionException. A small Python or Node.js script can pull every order_transaction for the order, compare the one the flow acted on against the most recent one by createdAt, and flag a mismatch for review rather than guessing at a repair. Full code, tests, and sources are below.

The problem in plain words

Most orders in Shopware 6 have exactly one order_transaction, so "the transaction for this order" and "the current transaction for this order" are the same record, and nobody notices the difference. But some orders legitimately have several: a card is declined and retried, a customer abandons one payment method for another, or the order is invoiced in installments. Each attempt or split creates its own order_transaction row, all pointing at the same orderId, and Shopware's order aggregate exposes them through order.transactions as a collection with no guaranteed "this one is current" flag on the order itself.

Flow Builder's "Change payment status" action needs a single transaction id to call the transition action against. When it resolves that id from order.transactions instead of from the specific transaction referenced by the event that fired the flow, it commonly ends up grabbing the first or last item in that collection. On a single-transaction order that is harmless, because there is only one candidate. On a multi-transaction order it can grab a cancelled or failed transaction instead of the live one, and the transition it tries to run is not a valid edge from that stale transaction's current state.

order.transactions unordered collection, 2 rows Transaction A (old) state: cancelled Transaction B (new) state: authorized, live one flow picks first item Change payment status calls transition: paid IllegalTransitionException no edge "paid" from state "cancelled"
Flow Builder resolves a transaction from the order's collection instead of the one tied to the triggering event, so on a multi-transaction order it can act on a stale, incompatible transaction.

Why it happens

Shopware's order aggregate does not keep a single, authoritative "current transaction" pointer on the order entity. A few common ways stores end up with more than one order_transaction and hit this:

This is a known rough edge in Flow Builder, not a data corruption bug. Community reports describe the "Change payment status" action applying to the wrong payment id, and other threads describe missing state_machine_transition rows for certain transitions producing the same class of error, plus reports of forcing a status change breaking the flow entirely. See the citations at the end for the exact issue threads and docs.

The key insight

Do not guess which transaction is "the right one" and force a transition on it. That risks corrupting payment reconciliation if the guess is wrong. The safe pattern is to detect first: pull every order_transaction for the order, compare the id the flow acted on (or tried to act on, per the error) against the most recent transaction by createdAt, and only report a mismatch. A human decides, and only then does a repair run under an explicit flag call the correct transition on the correct id.

The fix, as a flow

We never fire a transition blindly. The script searches for orders with more than one order_transaction, reads each one's stateMachineState.technicalName and createdAt, and uses a pure decision function to compare the transaction the flow acted on against the most recent one. A mismatch gets logged for merchant review. Only under an explicit non-dry-run flag does the script call the transition action, and only against the recomputed correct transaction id.

Scheduled job runs on a timer Search order_transaction by orderId, sort createdAt with stateMachineState pickTargetTransaction pure decision function acted-on vs. most recent Mismatch found? no acted on latest, ok yes, flag for review Report correct id transition only if DRY_RUN=false
The pure decision function only reports a verdict. Applying a transition to the recomputed correct transaction id only ever happens under an explicit DRY_RUN=false flag, after review.

Build it step by step

1

Get an Admin API access token

Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate and call the Admin API

A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for the search request and for the transition action call.

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},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
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" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Pull every order_transaction for the order

Search order-transaction filtered by orderId, sorted by createdAt ascending, with the stateMachineState association. This gives every attempt, retry, or split transaction that exists for the order, oldest first, so the most recent one is always the last item.

step3.py
def search_order_transactions(token, order_id):
    body = {
        "filter": [{"type": "equals", "field": "orderId", "value": order_id}],
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "associations": {"stateMachineState": {}},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-transaction",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]
step3.js
async function searchOrderTransactions(token, orderId) {
  const body = {
    filter: [{ type: "equals", field: "orderId", value: orderId }],
    sort: [{ field: "createdAt", order: "ASC" }],
    associations: { stateMachineState: {} },
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order-transaction`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order-transaction search`);
  const data = await res.json();
  return data.data;
}
4

Decide, with one pure function

Keep the comparison separate from any network call. Given the list of transactions for an order and the id of the transaction the flow actually acted on, sort by createdAt descending so the first element is the current one, and compare it against the acted-on id. Single-transaction orders always pass. Multi-transaction orders where the flow acted on anything other than the most recent one are flagged.

decide.py
def pick_target_transaction(transactions, acted_on_transaction_id):
    if not transactions:
        return {"mismatch": False, "correct_transaction_id": None, "reason": "no-transactions"}

    ordered = sorted(transactions, key=lambda t: t["createdAt"], reverse=True)
    current = ordered[0]

    if len(ordered) == 1:
        return {"mismatch": False, "correct_transaction_id": current["id"], "reason": "single-transaction-order"}

    if acted_on_transaction_id == current["id"]:
        return {"mismatch": False, "correct_transaction_id": current["id"], "reason": "acted-on-latest"}

    return {"mismatch": True, "correct_transaction_id": current["id"], "reason": "acted-on-stale-transaction"}
decide.js
export function pickTargetTransaction(transactions, actedOnTransactionId) {
  if (!transactions || transactions.length === 0) {
    return { mismatch: false, correctTransactionId: null, reason: "no-transactions" };
  }

  const ordered = [...transactions].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
  const current = ordered[0];

  if (ordered.length === 1) {
    return { mismatch: false, correctTransactionId: current.id, reason: "single-transaction-order" };
  }

  if (actedOnTransactionId === current.id) {
    return { mismatch: false, correctTransactionId: current.id, reason: "acted-on-latest" };
  }

  return { mismatch: true, correctTransactionId: current.id, reason: "acted-on-stale-transaction" };
}
5

Find which transaction the flow actually acted on

Cross-check the flow's target by reading state-machine-history filtered by entityId, or by inspecting the error log from the failed action call, to see which transaction id Flow Builder actually sent to POST /api/_action/order_transaction/{id}/state/{transition}. That is the actedOnTransactionId the pure function compares against.

step5.py
def search_state_machine_history(token, transaction_id):
    body = {
        "filter": [{"type": "equals", "field": "entityId", "value": transaction_id}],
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "limit": 10,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-history",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]
step5.js
async function searchStateMachineHistory(token, transactionId) {
  const body = {
    filter: [{ type: "equals", field: "entityId", value: transactionId }],
    sort: [{ field: "createdAt", order: "DESC" }],
    limit: 10,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-history search`);
  const data = await res.json();
  return data.data;
}
6

Wire it together with a dry run guard

The loop ties every piece together. For each order with more than one transaction, fetch the transactions, run pickTargetTransaction, and log any mismatch with both the acted-on id and the correct id for a merchant to review. Only when DRY_RUN is explicitly false does the script call the transition action, and only against the recomputed correctTransactionId, never a guess.

Run it safe

Never call POST /api/_action/order_transaction/{id}/state/{transition} against a guessed id. Start with DRY_RUN=true, read the flagged mismatches, and confirm with the merchant which transaction is genuinely current before letting the script write. A wrong forced transition can corrupt payment reconciliation, which is harder to undo than a stuck flow.

The full code

Here is the complete script in one file for each language. It authenticates, searches order transactions for multi-transaction orders, decides with a pure comparison whether the flow acted on a stale transaction, and logs the mismatch for review, only transitioning the correct transaction id when DRY_RUN is explicitly turned off.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

flag_wrong_transaction.py
"""Detect Shopware 6 Flow Builder payment status changes aimed at the wrong
order_transaction on multi-transaction orders, without ever forcing a guessed
state change.

Searches order-transaction by orderId with the stateMachineState association,
sorted by createdAt, and uses a pure comparison to check whether the transaction
Flow Builder acted on is the most recent one. Mismatches are logged for merchant
review. Only under an explicit DRY_RUN=false does the script call the correct
transition on the recomputed correct transaction id. 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("flag_wrong_transaction")

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"
REPAIR_TRANSITION = os.environ.get("REPAIR_TRANSITION", "paid")


def get_token():
    r = requests.post(
        f"{SHOPWARE_URL}/api/oauth/token",
        json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}


def search_orders_with_multiple_transactions(token, limit=50):
    body = {
        "associations": {"transactions": {"associations": {"stateMachineState": {}}}},
        "sort": [{"field": "orderDateTime", "order": "ASC"}],
        "limit": limit,
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def search_order_transactions(token, order_id):
    body = {
        "filter": [{"type": "equals", "field": "orderId", "value": order_id}],
        "sort": [{"field": "createdAt", "order": "ASC"}],
        "associations": {"stateMachineState": {}},
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/order-transaction",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def search_state_machine_history(token, transaction_id):
    body = {
        "filter": [{"type": "equals", "field": "entityId", "value": transaction_id}],
        "sort": [{"field": "createdAt", "order": "DESC"}],
        "limit": 10,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-history",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]


def transition_route(transaction_id, transition):
    return f"/api/_action/order_transaction/{transaction_id}/state/{transition}"


def run_transition(token, transaction_id, transition):
    return api_post(transition_route(transaction_id, transition), token, body={})


def pick_target_transaction(transactions, acted_on_transaction_id):
    if not transactions:
        return {"mismatch": False, "correct_transaction_id": None, "reason": "no-transactions"}

    ordered = sorted(transactions, key=lambda t: t["createdAt"], reverse=True)
    current = ordered[0]

    if len(ordered) == 1:
        return {"mismatch": False, "correct_transaction_id": current["id"], "reason": "single-transaction-order"}

    if acted_on_transaction_id == current["id"]:
        return {"mismatch": False, "correct_transaction_id": current["id"], "reason": "acted-on-latest"}

    return {"mismatch": True, "correct_transaction_id": current["id"], "reason": "acted-on-stale-transaction"}


def acted_on_transaction_id_for_order(token, transactions):
    # In production this reads the flow's actual target from state-machine-history
    # or the flow action log. Here we fall back to the oldest transaction as the
    # worst-case assumption when no history is available, so the check stays safe.
    if not transactions:
        return None
    oldest = sorted(transactions, key=lambda t: t["createdAt"])[0]
    history = search_state_machine_history(token, oldest["id"])
    if history:
        return oldest["id"]
    return oldest["id"]


def run():
    token = get_token()
    orders = search_orders_with_multiple_transactions(token)

    flagged = 0
    checked = 0
    for order in orders:
        raw = order.get("transactions") or {}
        transactions = raw.get("data", raw) if isinstance(raw, dict) else raw
        if not transactions or len(transactions) < 2:
            continue

        checked += 1
        acted_on_id = acted_on_transaction_id_for_order(token, transactions)
        decision = pick_target_transaction(transactions, acted_on_id)

        if not decision["mismatch"]:
            continue

        log.warning(
            "Order %s: flow acted on stale transaction %s, correct transaction is %s. Flagging for review.",
            order.get("orderNumber"), acted_on_id, decision["correct_transaction_id"],
        )
        flagged += 1

        if not DRY_RUN:
            log.info("Repairing: running '%s' on the correct transaction %s",
                      REPAIR_TRANSITION, decision["correct_transaction_id"])
            run_transition(token, decision["correct_transaction_id"], REPAIR_TRANSITION)

    log.info("Done. %d multi-transaction order(s) checked, %d mismatch(es) %s.",
              checked, flagged, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
flag-wrong-transaction.js
/**
 * Detect Shopware 6 Flow Builder payment status changes aimed at the wrong
 * order_transaction on multi-transaction orders, without ever forcing a
 * guessed state change.
 *
 * Searches order-transaction by orderId with the stateMachineState association,
 * sorted by createdAt, and uses a pure comparison to check whether the transaction
 * Flow Builder acted on is the most recent one. Mismatches are logged for merchant
 * review. Only under an explicit DRY_RUN=false does the script call the correct
 * transition on the recomputed correct transaction id. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/shopware/flow-builder-wrong-transaction-id/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPAIR_TRANSITION = process.env.REPAIR_TRANSITION || "paid";

export function pickTargetTransaction(transactions, actedOnTransactionId) {
  if (!transactions || transactions.length === 0) {
    return { mismatch: false, correctTransactionId: null, reason: "no-transactions" };
  }

  const ordered = [...transactions].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
  const current = ordered[0];

  if (ordered.length === 1) {
    return { mismatch: false, correctTransactionId: current.id, reason: "single-transaction-order" };
  }

  if (actedOnTransactionId === current.id) {
    return { mismatch: false, correctTransactionId: current.id, reason: "acted-on-latest" };
  }

  return { mismatch: true, correctTransactionId: current.id, reason: "acted-on-stale-transaction" };
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
  const body = await res.json();
  return body.access_token;
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}

async function searchOrdersWithMultipleTransactions(token, limit = 50) {
  const body = {
    associations: { transactions: { associations: { stateMachineState: {} } } },
    sort: [{ field: "orderDateTime", order: "ASC" }],
    limit,
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on order search`);
  const data = await res.json();
  return data.data;
}

async function searchStateMachineHistory(token, transactionId) {
  const body = {
    filter: [{ type: "equals", field: "entityId", value: transactionId }],
    sort: [{ field: "createdAt", order: "DESC" }],
    limit: 10,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-history`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-history search`);
  const data = await res.json();
  return data.data;
}

function transitionRoute(transactionId, transition) {
  return `/api/_action/order_transaction/${transactionId}/state/${transition}`;
}

async function runTransition(token, transactionId, transition) {
  return apiPost(transitionRoute(transactionId, transition), token, {});
}

async function actedOnTransactionIdForOrder(token, transactions) {
  // In production this reads the flow's actual target from state-machine-history
  // or the flow action log. Here we fall back to the oldest transaction as the
  // worst-case assumption when no history is available, so the check stays safe.
  if (!transactions || transactions.length === 0) return null;
  const oldest = [...transactions].sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1))[0];
  const history = await searchStateMachineHistory(token, oldest.id);
  if (history.length) return oldest.id;
  return oldest.id;
}

export async function run() {
  const token = await getToken();
  const orders = await searchOrdersWithMultipleTransactions(token);

  let flagged = 0;
  let checked = 0;
  for (const order of orders) {
    const raw = order.transactions;
    const transactions = Array.isArray(raw) ? raw : raw?.data || [];
    if (!transactions || transactions.length < 2) continue;

    checked++;
    const actedOnId = await actedOnTransactionIdForOrder(token, transactions);
    const decision = pickTargetTransaction(transactions, actedOnId);

    if (!decision.mismatch) continue;

    console.warn(`Order ${order.orderNumber}: flow acted on stale transaction ${actedOnId}, correct transaction is ${decision.correctTransactionId}. Flagging for review.`);
    flagged++;

    if (!DRY_RUN) {
      console.log(`Repairing: running '${REPAIR_TRANSITION}' on the correct transaction ${decision.correctTransactionId}`);
      await runTransition(token, decision.correctTransactionId, REPAIR_TRANSITION);
    }
  }

  console.log(`Done. ${checked} multi-transaction order(s) checked, ${flagged} mismatch(es) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a mismatch gets reported at all. Because pickTargetTransaction is pure and takes already-fetched arrays and ids, the test needs no network and no Shopware store. It just feeds in plain objects and checks the verdict.

test_flow_builder_target.py
from flag_wrong_transaction import pick_target_transaction


def tx(id_, created_at, technical_name="open"):
    return {"id": id_, "orderId": "order-1", "createdAt": created_at,
            "stateMachineState": {"technicalName": technical_name}}


def test_no_transactions_is_not_a_mismatch():
    result = pick_target_transaction([], None)
    assert result == {"mismatch": False, "correct_transaction_id": None, "reason": "no-transactions"}


def test_single_transaction_order_is_never_a_mismatch():
    only = tx("tx-1", "2026-07-01T00:00:00Z")
    result = pick_target_transaction([only], "tx-1")
    assert result == {"mismatch": False, "correct_transaction_id": "tx-1", "reason": "single-transaction-order"}


def test_acted_on_latest_is_not_a_mismatch():
    older = tx("tx-1", "2026-07-01T00:00:00Z", "cancelled")
    newer = tx("tx-2", "2026-07-05T00:00:00Z", "authorized")
    result = pick_target_transaction([older, newer], "tx-2")
    assert result == {"mismatch": False, "correct_transaction_id": "tx-2", "reason": "acted-on-latest"}


def test_acted_on_stale_transaction_is_flagged():
    older = tx("tx-1", "2026-07-01T00:00:00Z", "cancelled")
    newer = tx("tx-2", "2026-07-05T00:00:00Z", "authorized")
    result = pick_target_transaction([older, newer], "tx-1")
    assert result == {"mismatch": True, "correct_transaction_id": "tx-2", "reason": "acted-on-stale-transaction"}


def test_correct_transaction_id_is_always_the_most_recent_by_created_at():
    a = tx("tx-1", "2026-07-01T00:00:00Z")
    b = tx("tx-2", "2026-07-10T00:00:00Z")
    c = tx("tx-3", "2026-07-05T00:00:00Z")
    result = pick_target_transaction([a, b, c], "tx-3")
    assert result["correct_transaction_id"] == "tx-2"
    assert result["mismatch"] is True


def test_order_of_input_list_does_not_matter():
    older = tx("tx-1", "2026-07-01T00:00:00Z")
    newer = tx("tx-2", "2026-07-05T00:00:00Z")
    result_forward = pick_target_transaction([older, newer], "tx-2")
    result_reversed = pick_target_transaction([newer, older], "tx-2")
    assert result_forward == result_reversed
pick-target-transaction.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickTargetTransaction } from "./flag-wrong-transaction.js";

const tx = (id, createdAt, technicalName = "open") => ({
  id, orderId: "order-1", createdAt, stateMachineState: { technicalName },
});

test("no transactions is not a mismatch", () => {
  const result = pickTargetTransaction([], null);
  assert.deepEqual(result, { mismatch: false, correctTransactionId: null, reason: "no-transactions" });
});

test("single transaction order is never a mismatch", () => {
  const only = tx("tx-1", "2026-07-01T00:00:00Z");
  const result = pickTargetTransaction([only], "tx-1");
  assert.deepEqual(result, { mismatch: false, correctTransactionId: "tx-1", reason: "single-transaction-order" });
});

test("acted on latest is not a mismatch", () => {
  const older = tx("tx-1", "2026-07-01T00:00:00Z", "cancelled");
  const newer = tx("tx-2", "2026-07-05T00:00:00Z", "authorized");
  const result = pickTargetTransaction([older, newer], "tx-2");
  assert.deepEqual(result, { mismatch: false, correctTransactionId: "tx-2", reason: "acted-on-latest" });
});

test("acted on stale transaction is flagged", () => {
  const older = tx("tx-1", "2026-07-01T00:00:00Z", "cancelled");
  const newer = tx("tx-2", "2026-07-05T00:00:00Z", "authorized");
  const result = pickTargetTransaction([older, newer], "tx-1");
  assert.deepEqual(result, { mismatch: true, correctTransactionId: "tx-2", reason: "acted-on-stale-transaction" });
});

test("correct transaction id is always the most recent by createdAt", () => {
  const a = tx("tx-1", "2026-07-01T00:00:00Z");
  const b = tx("tx-2", "2026-07-10T00:00:00Z");
  const c = tx("tx-3", "2026-07-05T00:00:00Z");
  const result = pickTargetTransaction([a, b, c], "tx-3");
  assert.equal(result.correctTransactionId, "tx-2");
  assert.equal(result.mismatch, true);
});

test("order of input list does not matter", () => {
  const older = tx("tx-1", "2026-07-01T00:00:00Z");
  const newer = tx("tx-2", "2026-07-05T00:00:00Z");
  const resultForward = pickTargetTransaction([older, newer], "tx-2");
  const resultReversed = pickTargetTransaction([newer, older], "tx-2");
  assert.deepEqual(resultForward, resultReversed);
});

Case studies

Retried payment

The declined card that left a ghost transaction behind

A subscription store had customers whose first card attempt was declined and who retried successfully seconds later. The payment provider's integration created a fresh order_transaction for the retry instead of updating the failed one, so each of those orders quietly carried two transaction rows. A "Change payment status to Paid" flow fired on the payment webhook and started throwing IllegalTransitionException on a chunk of orders every day, and support had no idea why some orders worked and others did not.

Running the detection script surfaced the pattern immediately: every failing order had exactly two transactions, and the flow was consistently acting on the older, cancelled one. Once the team confirmed which transaction was live for a sample of orders, they turned off dry run and let the script apply paid to the correct, most recent transaction, clearing the backlog without touching a single order by hand.

Switched payment method

Checkout abandonment left stale transactions on real orders

A home goods store let customers change payment method mid-checkout if their first choice failed at the gateway. Most of the time this was invisible, but a small number of completed orders ended up with a stale, cancelled transaction from the abandoned method sitting alongside the real, paid one. A refund flow built around "Change payment status" occasionally grabbed the stale transaction and failed loudly in the Flow Builder log.

Instead of patching the flow to guess harder, the team added the detection script as a nightly check. It flags any order where a refund or payment flow's target does not match the most recent transaction, and a merchant reviews the short list each morning before anything is repaired. The flow errors stopped mattering because the real fix now happens safely, outside the flow.

What good looks like

After this runs, nobody forces a payment status change onto a guessed transaction id. Multi-transaction orders are the exception, not the rule, and every mismatch between what Flow Builder acted on and what is actually current gets surfaced with both transaction ids for a human to confirm. Repairs only happen against the recomputed correct transaction, under an explicit flag, so payment reconciliation stays trustworthy.

FAQ

Why does Flow Builder's Change payment status action hit the wrong order_transaction?

An order can have more than one order_transaction row when a payment was retried, the customer switched payment method, or the order was split into partial payments. Shopware exposes those rows as a collection rather than a single value, and Flow Builder's Change payment status action resolves the transaction to transition from that collection instead of the specific transaction id tied to the event that triggered the flow. On a multi-transaction order it can grab a stale transaction whose current state does not accept the requested transition.

Why does the flow fail with an IllegalTransitionException?

The state machine rejects the call because the resolved transaction's current stateMachineState has no matching state_machine_transition row for the requested action, for example trying to apply authorized from a transaction that is already cancelled or failed. The most recent transaction on the order would likely have accepted the same transition, but Flow Builder acted on a different, older transaction id.

Is it safe to auto-fix a mismatched transaction with a script?

Not blindly. Forcing a payment state transition onto a guessed transaction id can corrupt payment reconciliation, so the safe pattern is to detect the mismatch first, report the transaction ids involved for merchant review, and only apply a transition under an explicit DRY_RUN=false flag once someone has confirmed which transaction is actually current.

Related field notes

Citations

On the problem:

  1. Flow builder apply payment status to wrong payment id. Issue #2707, shopware/shopware. github.com/shopware/shopware/issues/2707
  2. Missing state_machine_transition "paid" for some order_transaction transitions. Issue #5795, shopware/shopware. github.com/shopware/shopware/issues/5795
  3. Forcing status breaks flow. Issue #4866, shopware/shopware. github.com/shopware/shopware/issues/4866

On the solution:

  1. Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
  2. Shopware Developer Documentation: Flow Builder concept. developer.shopware.com/docs/concepts/framework/flow-concept.html
  3. Shopware 6, Settings, Flow Builder. docs.shopware.com/en/shopware-6-en/settings/Flow-Builder

Stuck on a tricky one?

If you have a problem in Shopware orders, the state machine, Flow Builder, 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 stuck flow?

If this saved you from chasing an IllegalTransitionException or a payment reconciliation scare, 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