Custom authorize transition reuses core's authorize action name and collides with core authorized (non-deterministic, breaks other payment plugins)
Summary
The plugin registers a custom order_transaction.state transition whose action name is authorize, pointing to a custom authorize state. Shopware core already defines a transition with the same action name authorize that points to the core authorized state. This produces two transitions with an identical (from_state, action_name) pair but different destination states.
Core resolves a transition by returning the first matching transition and does not apply a tie-breaker between two candidates with the same (from_state, action_name). As a result, any code that fires the standard core action StateMachineTransitionActions::ACTION_AUTHORIZE (= 'authorize') from open or in_progress can non-deterministically land on the plugin's authorize state instead of core's authorized.
This is not just cosmetic for PAY.nl itself: it silently changes behavior for every other active payment plugin in the shop that uses the standard core authorize action. We hit it with the Riverty / AfterPay plugin.
Environment
- PAY.nl plugin:
paynlpaymentshopware6 v2.0.7
- Shopware: 6.6.10.18 (constraint in the plugin is
shopware/core: ^6.5)
- Also installed and active: Riverty / AfterPay plugin (
afterpaysw64 v2.2.2), which uses the standard core authorize action
Root cause
Migration1584438271InsertingStatuses.php adds a transition using core's reserved action name:
$transitions = [
[
'action_name' => 'authorize', // <-- core action name
'from_state_id' => $openStateMachineStateId,
'to_state_id' => $authorizeStateMachineStateId, // <-- custom PAY.nl state, not core 'authorized'
],
// ...
];
Migration1595405748AddTransitionsForInProgress.php adds the same collision from in_progress:
[
'action_name' => 'authorize',
'from_state_id' => $inProgressStateMachineStateId,
'to_state_id' => $authorizeStateMachineStateId,
],
After these migrations the order_transaction.state machine contains four transitions for the authorize action from two source states, two of them core and two of them PAY.nl:
| action_name |
from_state |
to_state |
added by |
authorize |
open |
authorized |
core |
authorize |
open |
authorize |
PAY.nl |
authorize |
in_progress |
authorized |
core |
authorize |
in_progress |
authorize |
PAY.nl |
Core's resolver (Shopware\Core\System\StateMachine\StateMachineRegistry) loads the transitions sorted only by actionName and, in getTransitionDestinationById(), returns the first transition whose actionName and fromState match. There is no secondary sort or tie-breaker, so which of the two authorize transitions "wins" is undefined and can flip between environments, MySQL query plans, or after a cache/container rebuild.
Impact / observed symptom
In a shop where both PAY.nl and the Riverty / AfterPay plugin are active, the AfterPay plugin calls the standard core action:
$stateMachineRegistry->transition(
new Transition('order_transaction', $transactionId, StateMachineTransitionActions::ACTION_AUTHORIZE, 'stateId'),
$context
);
For months this resolved to core authorized. After an unrelated deploy (which reshuffled the non-deterministic row order), the same call started resolving to PAY.nl's authorize state, so the admin order list now shows payment status "Authorize" / "Toestemming geven" instead of "Authorized" for Riverty / AfterPay orders. Because the resolution is non-deterministic, no code change in the AfterPay plugin is required to trigger this. Downstream automation keyed on the payment status (capture-on-ship, ERP export, flow builder) can misfire when transactions land on an unexpected state.
Two notes on scope. First, in this particular shop PAY.nl's authorize flow is never exercised: across roughly 180 PAY.nl order transactions, none ever fire the authorize action or sit on the plugin's custom states (authorize, verify, partly_captured, refunding), they only use core states (open, in_progress, paid, cancelled, failed). So here the collision only harmed the other plugin.
Second, and worse, PAY.nl reaches its own authorize state the same way any caller does: TransitionService::performTransition() fires the authorize action (status 95 STATUS_AUTHORIZE -> StateMachineStateEnum::ACTION_AUTHORIZE = 'authorize') via stateMachineRegistry->transition(new Transition(..., 'authorize', 'stateId')). That is the exact same by-action-name call, into the same collision, so PAY.nl's own authorize payments are subject to the same non-deterministic resolution and can land on core authorized instead of the plugin's authorize. In other words the custom authorize state is not reliably reachable even by PAY.nl itself, which is a strong argument for not registering a transition under core's authorize action name in the first place.
Detecting the collision (SQL)
This query lists every (from_state, action_name) pair in the order_transaction.state machine that resolves to more than one destination state. On an unaffected install it returns nothing; on an install with this plugin it returns the authorize collision:
SELECT
f.technical_name AS from_state,
smt.action_name,
COUNT(*) AS destinations,
GROUP_CONCAT(t.technical_name ORDER BY t.technical_name) AS to_states
FROM state_machine sm
JOIN state_machine_transition smt ON smt.state_machine_id = sm.id
JOIN state_machine_state f ON f.id = smt.from_state_id
JOIN state_machine_state t ON t.id = smt.to_state_id
WHERE sm.technical_name = 'order_transaction.state'
GROUP BY f.technical_name, smt.action_name
HAVING COUNT(*) > 1;
Actual output on the affected shop:
from_state | action_name | destinations | to_states
-------------+-------------+--------------+---------------------
in_progress | authorize | 2 | authorize,authorized
open | authorize | 2 | authorize,authorized
To see which side each transition comes from (core vs PAY.nl), the created_at gives it away: the core transitions are created when the state machine is seeded, the PAY.nl ones by this plugin's migrations.
SELECT
smt.action_name,
f.technical_name AS from_state,
t.technical_name AS to_state,
smt.created_at
FROM state_machine sm
JOIN state_machine_transition smt ON smt.state_machine_id = sm.id
JOIN state_machine_state f ON f.id = smt.from_state_id
JOIN state_machine_state t ON t.id = smt.to_state_id
WHERE sm.technical_name = 'order_transaction.state'
AND smt.action_name = 'authorize'
ORDER BY from_state, to_state;
Proposed fix
Do not register a second transition under core's reserved authorize action name. Options, in order of preference:
- Namespace the custom action name, e.g.
paynl_authorize, and fire that action from the PAY.nl code path (StateMachineStateEnum::ACTION_AUTHORIZE). This removes the collision entirely and keeps the custom authorize state reachable only by PAY.nl.
- If a distinct PAY.nl "authorize" resting state is not actually required, route PAY.nl authorize to core's existing
authorized state and drop the custom authorize state/transition.
The same care applies to any future custom transition: reusing a core StateMachineTransitionActions action name for a different destination state will collide the same way. (verify, partly_captured and refunding are not core action names, so today only authorize collides.)
Steps to reproduce
- Install PAY.nl plugin (migrations create the
authorize state and the open→authorize / in_progress→authorize transitions under action name authorize).
- Install a second active payment plugin that calls
StateMachineTransitionActions::ACTION_AUTHORIZE on its order transactions (in our case Riverty / AfterPay, afterpaysw64).
- Run the "Detecting the collision" query above and confirm it returns the two
authorize rows.
- Place/authorize an order with the second plugin.
- The transaction may land on PAY.nl's
authorize state instead of core authorized; because resolution is non-deterministic it can differ per environment and after cache/container rebuilds.
Custom
authorizetransition reuses core'sauthorizeaction name and collides with coreauthorized(non-deterministic, breaks other payment plugins)Summary
The plugin registers a custom
order_transaction.statetransition whose action name isauthorize, pointing to a customauthorizestate. Shopware core already defines a transition with the same action nameauthorizethat points to the coreauthorizedstate. This produces two transitions with an identical(from_state, action_name)pair but different destination states.Core resolves a transition by returning the first matching transition and does not apply a tie-breaker between two candidates with the same
(from_state, action_name). As a result, any code that fires the standard core actionStateMachineTransitionActions::ACTION_AUTHORIZE(= 'authorize') fromopenorin_progresscan non-deterministically land on the plugin'sauthorizestate instead of core'sauthorized.This is not just cosmetic for PAY.nl itself: it silently changes behavior for every other active payment plugin in the shop that uses the standard core authorize action. We hit it with the Riverty / AfterPay plugin.
Environment
paynlpaymentshopware6v2.0.7shopware/core: ^6.5)afterpaysw64v2.2.2), which uses the standard coreauthorizeactionRoot cause
Migration1584438271InsertingStatuses.phpadds a transition using core's reserved action name:Migration1595405748AddTransitionsForInProgress.phpadds the same collision fromin_progress:[ 'action_name' => 'authorize', 'from_state_id' => $inProgressStateMachineStateId, 'to_state_id' => $authorizeStateMachineStateId, ],After these migrations the
order_transaction.statemachine contains four transitions for theauthorizeaction from two source states, two of them core and two of them PAY.nl:authorizeauthorizedauthorizeauthorizeauthorizeauthorizedauthorizeauthorizeCore's resolver (
Shopware\Core\System\StateMachine\StateMachineRegistry) loads the transitions sorted only byactionNameand, ingetTransitionDestinationById(), returns the first transition whoseactionNameandfromStatematch. There is no secondary sort or tie-breaker, so which of the twoauthorizetransitions "wins" is undefined and can flip between environments, MySQL query plans, or after a cache/container rebuild.Impact / observed symptom
In a shop where both PAY.nl and the Riverty / AfterPay plugin are active, the AfterPay plugin calls the standard core action:
For months this resolved to core
authorized. After an unrelated deploy (which reshuffled the non-deterministic row order), the same call started resolving to PAY.nl'sauthorizestate, so the admin order list now shows payment status "Authorize" / "Toestemming geven" instead of "Authorized" for Riverty / AfterPay orders. Because the resolution is non-deterministic, no code change in the AfterPay plugin is required to trigger this. Downstream automation keyed on the payment status (capture-on-ship, ERP export, flow builder) can misfire when transactions land on an unexpected state.Two notes on scope. First, in this particular shop PAY.nl's authorize flow is never exercised: across roughly 180 PAY.nl order transactions, none ever fire the
authorizeaction or sit on the plugin's custom states (authorize,verify,partly_captured,refunding), they only use core states (open,in_progress,paid,cancelled,failed). So here the collision only harmed the other plugin.Second, and worse, PAY.nl reaches its own
authorizestate the same way any caller does:TransitionService::performTransition()fires theauthorizeaction (status 95STATUS_AUTHORIZE->StateMachineStateEnum::ACTION_AUTHORIZE = 'authorize') viastateMachineRegistry->transition(new Transition(..., 'authorize', 'stateId')). That is the exact same by-action-name call, into the same collision, so PAY.nl's own authorize payments are subject to the same non-deterministic resolution and can land on coreauthorizedinstead of the plugin'sauthorize. In other words the customauthorizestate is not reliably reachable even by PAY.nl itself, which is a strong argument for not registering a transition under core'sauthorizeaction name in the first place.Detecting the collision (SQL)
This query lists every
(from_state, action_name)pair in theorder_transaction.statemachine that resolves to more than one destination state. On an unaffected install it returns nothing; on an install with this plugin it returns theauthorizecollision:Actual output on the affected shop:
To see which side each transition comes from (core vs PAY.nl), the
created_atgives it away: the core transitions are created when the state machine is seeded, the PAY.nl ones by this plugin's migrations.Proposed fix
Do not register a second transition under core's reserved
authorizeaction name. Options, in order of preference:paynl_authorize, and fire that action from the PAY.nl code path (StateMachineStateEnum::ACTION_AUTHORIZE). This removes the collision entirely and keeps the customauthorizestate reachable only by PAY.nl.authorizedstate and drop the customauthorizestate/transition.The same care applies to any future custom transition: reusing a core
StateMachineTransitionActionsaction name for a different destination state will collide the same way. (verify,partly_capturedandrefundingare not core action names, so today onlyauthorizecollides.)Steps to reproduce
authorizestate and theopen→authorize/in_progress→authorizetransitions under action nameauthorize).StateMachineTransitionActions::ACTION_AUTHORIZEon its order transactions (in our case Riverty / AfterPay,afterpaysw64).authorizerows.authorizestate instead of coreauthorized; because resolution is non-deterministic it can differ per environment and after cache/container rebuilds.