Skip to content

Custom authorize transition reuses core's authorize action name and collides with core authorized (non-deterministic, breaks other payment plugins) #464

Description

@MelvinAchterhuis

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:

  1. 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.
  2. 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

  1. Install PAY.nl plugin (migrations create the authorize state and the open→authorize / in_progress→authorize transitions under action name authorize).
  2. Install a second active payment plugin that calls StateMachineTransitionActions::ACTION_AUTHORIZE on its order transactions (in our case Riverty / AfterPay, afterpaysw64).
  3. Run the "Detecting the collision" query above and confirm it returns the two authorize rows.
  4. Place/authorize an order with the second plugin.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions