Problem/Motivation

UnifiedCheckout (src/Plugin/Commerce/PaymentGateway/UnifiedCheckout.php) supports two independent ways of deciding whether a transaction should be captured immediately or only authorized:

1. A gateway-level static setting, transaction_type, read once in create() and cached on the $capture property:
// line 185-186
$instance->capture = $instance->configuration['transaction_type'] === self::COMMERCE_CYBERSOURCE_UC_TRANSACTION_AUTH_AND_CAPTURE;
2. A per-request capture context, built by UnifiedCheckoutForm::createContexts() from the transaction type the merchant picks on the order "Add payment" form (src/PluginForm/UnifiedCheckoutForm.php:110,127-128):
$transaction_type = $form_state->get('transaction_type');
...
$capture = $transaction_type == 'capture';
$contexts['capture'] = new Context(new ContextDefinition('boolean'), $capture);

getUnifiedCheckoutForm() correctly honors the per-request context when it builds the CyberSource capture context request that actually drives the remote transaction type (auth vs. capture) for the widget:
// line 1129-1133
if (isset($contexts['capture'])) {
$capture = $contexts['capture']->getContextValue();
}
else {
$capture = $this->capture;
}
That $capture local variable is later used to pick CAPTURE vs AUTH in generateUnifiedCheckoutCaptureContextRequest() (line 1042), so the remote CyberSource transaction is created with the type the merchant actually selected on the form.

However, once CyberSource redirects back and onReturn() calls createPaymentFromTransaction() (line 634) to record the local commerce_payment entity, that method has no $capture parameter and never receives the context at all — it falls back unconditionally to the plugin's static property:

// line 764, 769
protected function createPaymentFromTransaction(OrderInterface $order, \stdClass $transaction_response, array $return_response): PaymentInterface {
...
$payment = $payment_storage->create([
'state' => $this->capture ? 'completed' : 'authorization',
...
]);

So $this->capture — which reflects only the gateway's fixed transaction_type configuration — is used to set the local payment's state, while the context value that actually determined the real transaction type sent to CyberSource for that specific request is discarded.

Impact: whenever the merchant's per-payment choice on the order "Add payment" form (Authorize only vs. Authorize and capture) differs from the gateway's configured default transaction_type, the local Commerce payment ends up recorded in the wrong state relative to what actually happened at CyberSource:

- Gateway configured for "Authorization only", merchant picks "Capture" on the Add payment form → CyberSource captures the funds, but the local payment is saved with state = authorization instead of completed.
- Gateway configured for "Authorize and capture", merchant picks "Authorize only" on the Add payment form → CyberSource only authorizes, but the local payment is saved with state = completed as if it were captured.

This is a straight data-integrity bug: the local order/payment state diverges from the actual remote gateway state, and $this->capture can never reflect the outcome of the order payment add form for a configuration that differs from the gateway default.

Steps to reproduce

1. Configure a Unified Checkout payment gateway with transaction_type set to "Authorization only".
2. Place an order and pay normally at checkout (authorization is created, $this->capture is FALSE, works as expected).
3. As an admin, go to the order's Payments → Add payment form, select the gateway, and choose "Authorize and capture funds" as the transaction type for this specific payment, then complete the widget.
4. Observe: CyberSource actually captures the funds (verifiable in the CyberSource dashboard / API response), but the resulting commerce_payment entity is saved with state: authorization, not completed.

Proposed resolution

Pass the effective $capture value (the one already resolved from context in getUnifiedCheckoutForm(), or persisted alongside the transaction) into createPaymentFromTransaction() — e.g. add a bool $capture parameter to createPaymentFromTransaction() and thread it from onReturn() using the same context resolution logic used in getUnifiedCheckoutForm(), rather than reading $this->capture directly.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

vmarchuk created an issue. See original summary.

vmarchuk’s picture

vmarchuk’s picture

Status: Active » Needs review
adrianandres’s picture

Status: Needs review » Reviewed & tested by the community

adrianandres’s picture

Status: Reviewed & tested by the community » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.