🔄 Add session-based redirect flow handling post 3ds verification

This commit is contained in:
Daniel Dudzic 2025-06-23 13:39:57 +02:00
parent 27af1020ba
commit 2804b4157d
No known key found for this signature in database
GPG key ID: 31B40D33E3465483
5 changed files with 376 additions and 62 deletions

View file

@ -14,6 +14,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Authentication\SdkClientToken;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\Axo\Assets\AxoManager;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface;
@ -398,6 +399,19 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
'method' => $three_d_secure,
),
);
$experience_context_builder = $c->get( 'wcgateway.builder.experience-context' );
assert( $experience_context_builder instanceof ExperienceContextBuilder );
$data['experience_context'] = $experience_context_builder
->with_endpoint_return_urls()
->with_current_brand_name()
->with_current_locale()
->build()->to_array();
$data['transaction_context'] = array(
'soft_descriptor' => __( 'Card verification hold', 'woocommerce-paypal-payments' ),
);
}
return $data;

View file

@ -11,13 +11,18 @@ namespace WooCommerce\PayPalCommerce\Axo\Gateway;
use Psr\Log\LoggerInterface;
use Exception;
use WC_AJAX;
use WC_Order;
use WC_Payment_Gateway;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource;
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint;
use WooCommerce\PayPalCommerce\WcGateway\Exception\PayPalOrderMissingException;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewaySettingsRendererTrait;
@ -29,6 +34,8 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\ProcessPaymentTrait;
use WooCommerce\PayPalCommerce\WcGateway\Exception\GatewayGenericException;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\Messages;
use DomainException;
/**
* Class AXOGateway.
@ -232,12 +239,65 @@ class AxoGateway extends WC_Payment_Gateway {
$wc_order = wc_get_order( $order_id );
if ( ! is_a( $wc_order, WC_Order::class ) ) {
return $this->handle_payment_failure(
null,
new GatewayGenericException( new Exception( 'WC order was not found.' ) )
return array(
'result' => 'failure',
'message' => __( 'Order not found. Please try again.', 'woocommerce-paypal-payments' ),
);
}
// Check for stored 3DS errors.
$stored_error = $this->get_and_clear_stored_error();
if ( $stored_error ) {
return array(
'result' => 'failure',
'message' => $stored_error['message'],
);
}
$existing_order = $this->session_handler->order();
if ( $existing_order ) {
// Check if this session order belongs to current WC order and we're in 3DS context.
$session_order_belongs_to_current_wc_order = $this->session_order_matches_wc_order( $existing_order, $wc_order );
$is_3ds_context = $this->is_3ds_context();
if ( $session_order_belongs_to_current_wc_order && $is_3ds_context ) {
// This is a legitimate 3DS return for the current order.
try {
/**
* This filter controls if the method 'process()' from OrderProcessor will be called.
*/
$process = apply_filters( 'woocommerce_paypal_payments_before_order_process', true, $this, $wc_order );
if ( $process ) {
$this->order_processor->process_captured_and_authorized( $wc_order, $existing_order );
}
// Clear session after successful processing.
$this->session_handler->destroy_session_data();
WC()->cart->empty_cart();
return array(
'result' => 'success',
'redirect' => $this->get_return_url( $wc_order ),
);
} catch ( Exception $exception ) {
// Handle 3DS processing failures with universal error approach.
// Clear session data since payment failed.
$this->session_handler->destroy_session_data();
return array(
'result' => 'failure',
'message' => $this->get_user_friendly_error_message( $exception ),
);
}
} else {
// Session order doesn't belong to current WC order OR not 3DS context.
$this->session_handler->destroy_session_data();
}
}
// No existing order or cleared session - this is an initial payment.
try {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) );
@ -251,38 +311,28 @@ class AxoGateway extends WC_Payment_Gateway {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$token = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) );
$order = $this->create_paypal_order( $wc_order, $token );
// Check if 3DS verification is required
$payer_action = '';
foreach ( $order->links() as $link ) {
if ( $link->rel === 'payer-action' ) {
$payer_action = $link->href;
}
// Enhanced token validation with universal error handling.
if ( empty( $token ) ) {
// Universal error return.
return array(
'result' => 'failure',
'message' => $this->is_3ds_context()
? __( 'Payment session expired. Please try your payment again.', 'woocommerce-paypal-payments' )
: __( 'No payment token provided. Please try again.', 'woocommerce-paypal-payments' ),
);
}
$this->logger->debug(
'AXO order created',
array(
'result' => 'success',
'return_url' => $this->get_return_url( $wc_order ),
'payer_action' => $payer_action,
)
);
$order = $this->create_paypal_order( $wc_order, $token );
// If 3DS verification is required, append the redirect_uri and return the redirect URL
$this->logger->debug(
'AXO order created',
array(
'result' => 'success',
'return_url' => $this->get_return_url( $wc_order ),
'payer_action' => $payer_action,
)
);
// Check if 3DS verification is required.
$payer_action = $this->get_payer_action_url( $order );
// If 3DS verification is required, append the redirect_uri and return the redirect URL
// If 3DS verification is required, store order and redirect.
if ( $payer_action ) {
$return_url = $this->get_return_url( $wc_order );
// Store the order in session before 3DS redirect.
$this->session_handler->replace_order( $order );
$return_url = home_url( WC_AJAX::get_endpoint( ReturnUrlEndpoint::ENDPOINT ) );
$redirect_url = add_query_arg(
'redirect_uri',
@ -309,7 +359,11 @@ class AxoGateway extends WC_Payment_Gateway {
$this->order_processor->process_captured_and_authorized( $wc_order, $order );
}
} catch ( Exception $exception ) {
return $this->handle_payment_failure( $wc_order, $exception );
// Error handling for initial payment failures.
return array(
'result' => 'failure',
'message' => $this->get_user_friendly_error_message( $exception ),
);
}
WC()->cart->empty_cart();
@ -320,6 +374,105 @@ class AxoGateway extends WC_Payment_Gateway {
);
}
/**
* Get and clear stored payment errors.
*/
private function get_and_clear_stored_error() {
if ( ! WC()->session ) {
return null;
}
$stored_error = WC()->session->get( 'ppcp_payment_error' );
if ( $stored_error ) {
WC()->session->__unset( 'ppcp_payment_error' );
return $stored_error;
}
return null;
}
/**
* Convert exceptions to user-friendly messages.
*/
private function get_user_friendly_error_message( Exception $exception ) {
$error_message = $exception->getMessage();
// Handle specific error types with user-friendly messages.
if ( $exception instanceof DomainException ) {
if ( strpos( $error_message, 'Could not capture' ) !== false ) {
return __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' );
}
}
if ( strpos( $error_message, 'declined' ) !== false ||
strpos( $error_message, 'PAYMENT_DENIED' ) !== false ||
strpos( $error_message, 'INSTRUMENT_DECLINED' ) !== false ||
strpos( $error_message, 'Payment provider declined' ) !== false ) {
return __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' );
}
if ( strpos( $error_message, 'session' ) !== false ||
strpos( $error_message, 'expired' ) !== false ) {
return __( 'Payment session expired. Please try your payment again.', 'woocommerce-paypal-payments' );
}
return __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' );
}
/**
* Extract payer action URL from PayPal order.
*/
private function get_payer_action_url( Order $order ) {
foreach ( $order->links() as $link ) {
if ( $link->rel === 'payer-action' ) {
return $link->href;
}
}
return '';
}
/**
* Check if session order belongs to current WC order.
*
* @param Order $paypal_order The PayPal order from session.
* @param WC_Order $wc_order The current WooCommerce order.
* @return bool
*/
private function session_order_matches_wc_order( Order $paypal_order, WC_Order $wc_order ): bool {
$paypal_custom_id = $paypal_order->purchase_units()[0]->custom_id() ?? '';
$wc_order_id = (string) $wc_order->get_id();
return $paypal_custom_id === $wc_order_id;
}
/**
* Check if we're in a 3DS return context.
*
* @return bool
*/
private function is_3ds_context(): bool {
// Check referer for PayPal.
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if ( strpos( $referer, 'paypal.com' ) !== false ) {
return true;
}
// Check for 3DS-specific parameters.
$three_ds_params = array( 'liability_shift', 'state', 'code', 'authentication_state' );
foreach ( $three_ds_params as $param ) {
if ( isset( $_GET[ $param ] ) ) {
return true;
}
}
// Check if we're being called by ReturnUrlEndpoint.
if ( isset( $_GET['wc-ajax'] ) && $_GET['wc-ajax'] === 'ppc-return-url' ) {
return true;
}
return false;
}
/**
* Create a new PayPal order from the existing WC_Order instance.
*
@ -346,15 +499,6 @@ class AxoGateway extends WC_Payment_Gateway {
$payment_source_properties
);
$this->logger->debug(
'AXO full order endpoint data',
array(
'purchase_unit' => $purchase_unit,
'shipping_preference' => $shipping_preference,
'payment_source' => $payment_source,
)
);
return $this->order_endpoint->create(
array( $purchase_unit ),
$shipping_preference,