Merge pull request #3493 from woocommerce/PCP-4487-fastlane-uk-3ds-redirect

Add Fastlane 3D Secure support and enable for the UK (4487)
This commit is contained in:
Emili Castells 2025-07-07 08:57:57 +02:00 committed by GitHub
commit 5fadc377d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 670 additions and 144 deletions

View file

@ -70,6 +70,10 @@ class Order {
* @var PaymentSource|null
*/
private $payment_source;
/**
* @var mixed|null
*/
private $links;
/**
* Order constructor.
@ -93,7 +97,8 @@ class Order {
Payer $payer = null,
string $intent = 'CAPTURE',
\DateTime $create_time = null,
\DateTime $update_time = null
\DateTime $update_time = null,
$links = null
) {
$this->id = $id;
@ -104,6 +109,7 @@ class Order {
$this->create_time = $create_time;
$this->update_time = $update_time;
$this->payment_source = $payment_source;
$this->links = $links;
}
/**
@ -179,6 +185,15 @@ class Order {
return $this->payment_source;
}
/**
* Returns the links.
*
* @return mixed|null
*/
public function links() {
return $this->links;
}
/**
* Returns the object as array.
*
@ -206,6 +221,10 @@ class Order {
$order['update_time'] = $this->update_time()->format( 'Y-m-d\TH:i:sO' );
}
if ( $this->links ) {
$order['links'] = $this->links();
}
return $order;
}
}

View file

@ -198,12 +198,15 @@ class AmountFactory {
/**
* Returns an Amount object based off a PayPal Response.
*
* @param \stdClass $data The JSON object.
* @param mixed $data The JSON object.
*
* @return Amount
* @throws RuntimeException When JSON object is malformed.
* @return Amount|null
*/
public function from_paypal_response( \stdClass $data ): Amount {
public function from_paypal_response( $data ) {
if ( null === $data || ! $data instanceof \stdClass ) {
return null;
}
$money = $this->money_factory->from_paypal_response( $data );
$breakdown = ( isset( $data->breakdown ) ) ? $this->break_down( $data->breakdown ) : null;
return new Amount( $money, $breakdown );

View file

@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatusDetails;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class CaptureFactory
@ -63,6 +64,7 @@ class CaptureFactory {
* @param \stdClass $data The PayPal response.
*
* @return Capture
* @throws RuntimeException When capture amount data is invalid.
*/
public function from_paypal_response( \stdClass $data ) : Capture {
$reason = $data->status_details->reason ?? null;
@ -74,13 +76,18 @@ class CaptureFactory {
$this->fraud_processor_response_factory->from_paypal_response( $data->processor_response )
: null;
$amount = $this->amount_factory->from_paypal_response( $data->amount );
if ( null === $amount ) {
throw new RuntimeException( __( 'Invalid capture amount data.', 'woocommerce-paypal-payments' ) );
}
return new Capture(
(string) $data->id,
new CaptureStatus(
(string) $data->status,
$reason ? new CaptureStatusDetails( $reason ) : null
),
$this->amount_factory->from_paypal_response( $data->amount ),
$amount,
(bool) $data->final_capture,
(string) $data->seller_protection->status,
(string) $data->invoice_id,

View file

@ -68,7 +68,8 @@ class OrderFactory {
$order->payer(),
$order->intent(),
$order->create_time(),
$order->update_time()
$order->update_time(),
$order->links()
);
}
@ -81,70 +82,155 @@ class OrderFactory {
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( \stdClass $order_data ): Order {
$this->validate_order_id( $order_data );
$purchase_units = $this->create_purchase_units( $order_data );
$status = $this->create_order_status( $order_data );
$intent = $this->get_intent( $order_data );
$timestamps = $this->create_timestamps( $order_data );
$payer = $this->create_payer( $order_data );
$payment_source = $this->create_payment_source( $order_data );
$links = $order_data->links ?? null;
return new Order(
$order_data->id,
$purchase_units,
$status,
$payment_source,
$payer,
$intent,
$timestamps['create_time'],
$timestamps['update_time'],
$links
);
}
/**
* Validates that the order data contains a required ID.
*
* @param \stdClass $order_data The order data.
*
* @throws RuntimeException When ID is missing.
*/
private function validate_order_id( \stdClass $order_data ): void {
if ( ! isset( $order_data->id ) ) {
throw new RuntimeException(
__( 'Order does not contain an id.', 'woocommerce-paypal-payments' )
);
}
}
/**
* Creates purchase units from order data.
*
* @param \stdClass $order_data The order data.
*
* @return array Array of PurchaseUnit objects.
*/
private function create_purchase_units( \stdClass $order_data ): array {
if ( ! isset( $order_data->purchase_units ) || ! is_array( $order_data->purchase_units ) ) {
throw new RuntimeException(
__( 'Order does not contain items.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $order_data->status ) ) {
throw new RuntimeException(
__( 'Order does not contain status.', 'woocommerce-paypal-payments' )
);
}
if ( ! isset( $order_data->intent ) ) {
throw new RuntimeException(
__( 'Order does not contain intent.', 'woocommerce-paypal-payments' )
);
return array();
}
$purchase_units = array_map(
function ( \stdClass $data ): PurchaseUnit {
return $this->purchase_unit_factory->from_paypal_response( $data );
},
$order_data->purchase_units
);
$create_time = ( isset( $order_data->create_time ) ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time )
: null;
$update_time = ( isset( $order_data->update_time ) ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time )
: null;
$payer = ( isset( $order_data->payer ) ) ?
$this->payer_factory->from_paypal_response( $order_data->payer )
: null;
$payment_source = null;
if ( isset( $order_data->payment_source ) ) {
$json_encoded_payment_source = wp_json_encode( $order_data->payment_source );
if ( $json_encoded_payment_source ) {
$payment_source_as_array = json_decode( $json_encoded_payment_source, true );
if ( $payment_source_as_array ) {
$name = array_key_first( $payment_source_as_array );
if ( $name ) {
$payment_source = new PaymentSource(
$name,
$order_data->payment_source->$name
);
}
}
$purchase_units = array();
foreach ( $order_data->purchase_units as $data ) {
$purchase_unit = $this->purchase_unit_factory->from_paypal_response( $data );
if ( null !== $purchase_unit ) {
$purchase_units[] = $purchase_unit;
}
}
return new Order(
$order_data->id,
$purchase_units,
new OrderStatus( $order_data->status ),
$payment_source,
$payer,
$order_data->intent,
$create_time,
$update_time
return $purchase_units;
}
/**
* Creates order status from order data.
*
* @param \stdClass $order_data The order data.
*
* @return OrderStatus
*/
private function create_order_status( \stdClass $order_data ): OrderStatus {
$status_value = $order_data->status ?? 'PAYER_ACTION_REQUIRED';
return new OrderStatus( $status_value );
}
/**
* Gets the intent from order data.
*
* @param \stdClass $order_data The order data.
*
* @return string
*/
private function get_intent( \stdClass $order_data ): string {
return $order_data->intent ?? 'CAPTURE';
}
/**
* Creates timestamps from order data.
*
* @param \stdClass $order_data The order data.
*
* @return array Array with 'create_time' and 'update_time' keys.
*/
private function create_timestamps( \stdClass $order_data ): array {
$create_time = isset( $order_data->create_time ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->create_time ) :
null;
$update_time = isset( $order_data->update_time ) ?
\DateTime::createFromFormat( 'Y-m-d\TH:i:sO', $order_data->update_time ) :
null;
return array(
'create_time' => $create_time,
'update_time' => $update_time,
);
}
/**
* Creates payer from order data.
*
* @param \stdClass $order_data The order data.
*
* @return mixed Payer object or null.
*/
private function create_payer( \stdClass $order_data ) {
return isset( $order_data->payer ) ?
$this->payer_factory->from_paypal_response( $order_data->payer ) :
null;
}
/**
* Creates payment source from order data.
*
* @param \stdClass $order_data The order data.
*
* @return PaymentSource|null
*/
private function create_payment_source( \stdClass $order_data ): ?PaymentSource {
if ( ! isset( $order_data->payment_source ) ) {
return null;
}
$json_encoded_payment_source = wp_json_encode( $order_data->payment_source );
if ( ! $json_encoded_payment_source ) {
return null;
}
$payment_source_as_array = json_decode( $json_encoded_payment_source, true );
if ( ! $payment_source_as_array ) {
return null;
}
$source_name = array_key_first( $payment_source_as_array );
if ( ! $source_name ) {
return null;
}
return new PaymentSource(
$source_name,
$order_data->payment_source->$source_name
);
}
}

View file

@ -219,17 +219,22 @@ class PurchaseUnitFactory {
*
* @param \stdClass $data The JSON object.
*
* @return PurchaseUnit
* @return ?PurchaseUnit
* @throws RuntimeException When JSON object is malformed.
*/
public function from_paypal_response( \stdClass $data ): PurchaseUnit {
public function from_paypal_response( \stdClass $data ): ?PurchaseUnit {
if ( ! isset( $data->reference_id ) || ! is_string( $data->reference_id ) ) {
throw new RuntimeException(
__( 'No reference ID given.', 'woocommerce-paypal-payments' )
);
}
$amount = $this->amount_factory->from_paypal_response( $data->amount );
$amount_data = $data->amount ?? null;
$amount = $this->amount_factory->from_paypal_response( $amount_data );
if ( null === $amount ) {
return null;
}
$description = ( isset( $data->description ) ) ? $data->description : '';
$custom_id = ( isset( $data->custom_id ) ) ? $data->custom_id : '';
$invoice_id = ( isset( $data->invoice_id ) ) ? $data->invoice_id : '';

View file

@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\RefundStatusDetails;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
* Class RefundFactory
@ -62,6 +63,7 @@ class RefundFactory {
* @param \stdClass $data The PayPal response.
*
* @return Refund
* @throws RuntimeException When refund amount data is invalid.
*/
public function from_paypal_response( \stdClass $data ) : Refund {
$reason = $data->status_details->reason ?? null;
@ -73,13 +75,18 @@ class RefundFactory {
$this->refund_payer_factory->from_paypal_response( $data->payer )
: null;
$amount = $this->amount_factory->from_paypal_response( $data->amount );
if ( null === $amount ) {
throw new RuntimeException( __( 'Invalid refund amount data.', 'woocommerce-paypal-payments' ) );
}
return new Refund(
(string) $data->id,
new RefundStatus(
(string) $data->status,
$reason ? new RefundStatusDetails( $reason ) : null
),
$this->amount_factory->from_paypal_response( $data->amount ),
$amount,
(string) ( $data->invoice_id ?? '' ),
(string) ( $data->custom_id ?? '' ),
$seller_payable_breakdown,

View file

@ -99,7 +99,9 @@ return array(
$container->get( 'api.factory.shipping-preference' ),
$container->get( 'wcgateway.transaction-url-provider' ),
$container->get( 'settings.environment' ),
$container->get( 'woocommerce.logger.woocommerce' )
$container->get( 'woocommerce.logger.woocommerce' ),
$container->get( 'wcgateway.builder.experience-context' ),
$container->get( 'settings.data.settings' )
);
},
@ -156,46 +158,63 @@ return array(
* The matrix which countries and currency combinations can be used for AXO.
*/
'axo.supported-country-currency-matrix' => static function ( ContainerInterface $container ) : array {
$matrix = array(
'US' => array(
'AUD',
'CAD',
'EUR',
'GBP',
'JPY',
'USD',
),
);
if ( $container->get( 'axo.uk.enabled' ) ) {
$matrix['GB'] = array( 'GBP' );
}
/**
* Returns which countries and currency combinations can be used for AXO.
*/
return apply_filters(
'woocommerce_paypal_payments_axo_supported_country_currency_matrix',
array(
'US' => array(
'AUD',
'CAD',
'EUR',
'GBP',
'JPY',
'USD',
),
)
$matrix
);
},
/**
* The matrix which countries and card type combinations can be used for AXO.
*/
'axo.supported-country-card-type-matrix' => static function ( ContainerInterface $container ) : array {
$matrix = array(
'US' => array(
'VISA',
'MASTERCARD',
'AMEX',
'DISCOVER',
),
'CA' => array(
'VISA',
'MASTERCARD',
'AMEX',
'DISCOVER',
),
);
if ( $container->get( 'axo.uk.enabled' ) ) {
$matrix['GB'] = array(
'VISA',
'MASTERCARD',
'AMEX',
'DISCOVER',
);
}
/**
* Returns which countries and card type combinations can be used for AXO.
*/
return apply_filters(
'woocommerce_paypal_payments_axo_supported_country_card_type_matrix',
array(
'US' => array(
'VISA',
'MASTERCARD',
'AMEX',
'DISCOVER',
),
'CA' => array(
'VISA',
'MASTERCARD',
'AMEX',
'DISCOVER',
),
)
$matrix
);
},
'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string {
@ -379,4 +398,17 @@ return array(
)
);
},
'axo.uk.enabled' => static function ( ContainerInterface $container ): bool {
// phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores
/**
* Filter to determine if Fastlane UK with 3D Secure should be enabled.
*
* @param bool $enabled Whether Fastlane UK is enabled.
*/
return apply_filters(
'woocommerce.feature-flags.woocommerce_paypal_payments.axo_uk_enabled',
getenv( 'PCP_AXO_UK_ENABLED' ) !== '0'
);
// phpcs:enable WordPress.NamingConventions.ValidHookName.UseUnderscores
},
);

View file

@ -11,13 +11,17 @@ 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\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint;
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewaySettingsRendererTrait;
@ -29,6 +33,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\Settings\Data\SettingsModel;
use DomainException;
/**
* Class AXOGateway.
@ -129,6 +135,20 @@ class AxoGateway extends WC_Payment_Gateway {
*/
protected $session_handler;
/**
* The experience context builder.
*
* @var ExperienceContextBuilder
*/
protected $experience_context_builder;
/**
* The settings model.
*
* @var SettingsModel
*/
protected $settings_model;
/**
* AXOGateway constructor.
*
@ -145,6 +165,8 @@ class AxoGateway extends WC_Payment_Gateway {
* @param TransactionUrlProvider $transaction_url_provider The transaction url provider.
* @param Environment $environment The environment.
* @param LoggerInterface $logger The logger.
* @param ExperienceContextBuilder $experience_context_builder The experience context builder.
* @param SettingsModel $settings_model The settings model.
*/
public function __construct(
SettingsRenderer $settings_renderer,
@ -159,17 +181,21 @@ class AxoGateway extends WC_Payment_Gateway {
ShippingPreferenceFactory $shipping_preference_factory,
TransactionUrlProvider $transaction_url_provider,
Environment $environment,
LoggerInterface $logger
LoggerInterface $logger,
ExperienceContextBuilder $experience_context_builder,
SettingsModel $settings_model
) {
$this->id = self::ID;
$this->settings_renderer = $settings_renderer;
$this->ppcp_settings = $ppcp_settings;
$this->dcc_configuration = $dcc_configuration;
$this->wcgateway_module_url = $wcgateway_module_url;
$this->session_handler = $session_handler;
$this->order_processor = $order_processor;
$this->card_icons = $card_icons;
$this->settings_renderer = $settings_renderer;
$this->ppcp_settings = $ppcp_settings;
$this->dcc_configuration = $dcc_configuration;
$this->wcgateway_module_url = $wcgateway_module_url;
$this->session_handler = $session_handler;
$this->order_processor = $order_processor;
$this->card_icons = $card_icons;
$this->experience_context_builder = $experience_context_builder;
$this->settings_model = $settings_model;
$this->method_title = __( 'Fastlane Debit & Credit Cards', 'woocommerce-paypal-payments' );
$this->method_description = __( 'Fastlane accelerates the checkout experience for guest shoppers and autofills their details so they can pay in seconds. When enabled, Fastlane is presented as the default payment method for guests.', 'woocommerce-paypal-payments' );
@ -234,10 +260,19 @@ class AxoGateway extends WC_Payment_Gateway {
if ( ! is_a( $wc_order, WC_Order::class ) ) {
return $this->handle_payment_failure(
null,
new GatewayGenericException( new Exception( 'WC order was not found.' ) )
new GatewayGenericException( new Exception( 'WC order was not found.' ) ),
);
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$axo_nonce = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$token_param = wc_clean( wp_unslash( $_GET['token'] ?? '' ) );
if ( empty( $axo_nonce ) && ! empty( $token_param ) ) {
return $this->process_3ds_return( $wc_order, $token_param );
}
try {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) );
@ -248,10 +283,37 @@ class AxoGateway extends WC_Payment_Gateway {
}
// The `axo_nonce` is not a WP nonce, but a card-token generated by the JS SDK.
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$token = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) );
if ( empty( $axo_nonce ) ) {
return array(
'result' => 'failure',
'message' => __( 'No payment token provided. Please try again.', 'woocommerce-paypal-payments' ),
);
}
$order = $this->create_paypal_order( $wc_order, $token );
$order = $this->create_paypal_order( $wc_order, $axo_nonce );
// Check if 3DS verification is required.
$payer_action = $this->get_payer_action_url( $order );
// If 3DS verification is required, redirect with token in return URL.
if ( $payer_action ) {
$return_url = add_query_arg(
'token',
$order->id(),
home_url( WC_AJAX::get_endpoint( ReturnUrlEndpoint::ENDPOINT ) )
);
$redirect_url = add_query_arg(
'redirect_uri',
rawurlencode( $return_url ),
$payer_action
);
return array(
'result' => 'success',
'redirect' => $redirect_url,
);
}
/**
* This filter controls if the method 'process()' from OrderProcessor will be called.
@ -266,7 +328,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 );
$this->logger->error( '[AXO] Payment processing failed: ' . $exception->getMessage() );
return array(
'result' => 'failure',
'message' => $this->get_user_friendly_error_message( $exception ),
);
}
WC()->cart->empty_cart();
@ -277,6 +343,103 @@ class AxoGateway extends WC_Payment_Gateway {
);
}
/**
* Process 3DS return scenario.
*
* @param WC_Order $wc_order The WooCommerce order.
* @param string $token The PayPal order token.
*
* @return array
*/
protected function process_3ds_return( WC_Order $wc_order, string $token ) : array {
try {
$paypal_order = $this->order_endpoint->order( $token );
if ( ! $paypal_order->status()->is( OrderStatus::COMPLETED ) ) {
return array(
'result' => 'failure',
'message' => __( '3D Secure authentication was not completed successfully. Please try again.', 'woocommerce-paypal-payments' ),
);
}
/**
* This filter controls if the method 'process()' from OrderProcessor will be called.
* So you can implement your own for example on subscriptions
*
* - true bool controls execution of 'OrderProcessor::process()'
* - $this \WC_Payment_Gateway
* - $wc_order \WC_Order
*/
$process = apply_filters( 'woocommerce_paypal_payments_before_order_process', true, $this, $wc_order );
if ( $process ) {
$this->order_processor->process_captured_and_authorized( $wc_order, $paypal_order );
}
} catch ( Exception $exception ) {
$this->logger->error( '[AXO] 3DS return processing failed: ' . $exception->getMessage() );
return array(
'result' => 'failure',
'message' => $this->get_user_friendly_error_message( $exception ),
);
}
WC()->cart->empty_cart();
return array(
'result' => 'success',
'redirect' => $this->get_return_url( $wc_order ),
);
}
/**
* 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.
*
* @param Order $order The PayPal order.
* @return string The payer action URL or an empty string if not found.
*/
private function get_payer_action_url( Order $order ) : string {
$links = $order->links();
if ( ! $links ) {
return '';
}
foreach ( $links as $link ) {
if ( isset( $link->rel ) && $link->rel === 'payer-action' ) {
return $link->href ?? '';
}
}
return '';
}
/**
* Create a new PayPal order from the existing WC_Order instance.
*
@ -293,9 +456,8 @@ class AxoGateway extends WC_Payment_Gateway {
'checkout'
);
$payment_source_properties = (object) array(
'single_use_token' => $payment_token,
);
// Build payment source with 3DS verification if needed.
$payment_source_properties = $this->build_payment_source_properties( $payment_token );
$payment_source = new PaymentSource(
'card',
@ -306,12 +468,64 @@ class AxoGateway extends WC_Payment_Gateway {
array( $purchase_unit ),
$shipping_preference,
null,
'',
array(),
$payment_source
self::ID,
$this->build_order_data(),
$payment_source,
$wc_order
);
}
/**
* Build payment source properties.
*
* @param string $payment_token The payment token.
* @return object The payment source properties.
*/
protected function build_payment_source_properties( string $payment_token ): object {
$properties = array(
'single_use_token' => $payment_token,
);
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
if ( 'SCA_ALWAYS' === $three_d_secure || 'SCA_WHEN_REQUIRED' === $three_d_secure ) {
$properties['attributes'] = array(
'verification' => array(
'method' => $three_d_secure,
),
);
}
return (object) $properties;
}
/**
* Build additional order data for experience context and 3DS verification.
*
* @return array The order data.
*/
protected function build_order_data(): array {
$data = array();
$experience_context = $this->experience_context_builder
->with_endpoint_return_urls()
->with_current_brand_name()
->with_current_locale()
->build();
$data['experience_context'] = $experience_context->to_array();
$three_d_secure = $this->settings_model->get_three_d_secure_enum();
if ( $three_d_secure === 'SCA_ALWAYS' || $three_d_secure === 'SCA_WHEN_REQUIRED' ) {
$data['transaction_context'] = array(
'soft_descriptor' => __( 'Card verification hold', 'woocommerce-paypal-payments' ),
);
}
return $data;
}
/**
* Returns the icons of the gateway.
*

View file

@ -232,24 +232,24 @@ class CreateOrderEndpoint implements EndpointInterface {
LoggerInterface $logger
) {
$this->request_data = $request_data;
$this->purchase_unit_factory = $purchase_unit_factory;
$this->shipping_preference_factory = $shipping_preference_factory;
$this->contact_preference_factory = $contact_preference_factory;
$this->experience_context_builder = $experience_context_builder;
$this->api_endpoint = $order_endpoint;
$this->payer_factory = $payer_factory;
$this->session_handler = $session_handler;
$this->settings = $settings;
$this->early_order_handler = $early_order_handler;
$this->registration_needed = $registration_needed;
$this->card_billing_data_mode = $card_billing_data_mode;
$this->early_validation_enabled = $early_validation_enabled;
$this->pay_now_contexts = $pay_now_contexts;
$this->handle_shipping_in_paypal = $handle_shipping_in_paypal;
$this->request_data = $request_data;
$this->purchase_unit_factory = $purchase_unit_factory;
$this->shipping_preference_factory = $shipping_preference_factory;
$this->contact_preference_factory = $contact_preference_factory;
$this->experience_context_builder = $experience_context_builder;
$this->api_endpoint = $order_endpoint;
$this->payer_factory = $payer_factory;
$this->session_handler = $session_handler;
$this->settings = $settings;
$this->early_order_handler = $early_order_handler;
$this->registration_needed = $registration_needed;
$this->card_billing_data_mode = $card_billing_data_mode;
$this->early_validation_enabled = $early_validation_enabled;
$this->pay_now_contexts = $pay_now_contexts;
$this->handle_shipping_in_paypal = $handle_shipping_in_paypal;
$this->server_side_shipping_callback_enabled = $server_side_shipping_callback_enabled;
$this->funding_sources_without_redirect = $funding_sources_without_redirect;
$this->logger = $logger;
$this->funding_sources_without_redirect = $funding_sources_without_redirect;
$this->logger = $logger;
}
/**

View file

@ -230,6 +230,27 @@ class SettingsModel extends AbstractDataModel {
return $this->data['three_d_secure'];
}
/**
* Converts the 3D Secure setting value to the corresponding API enum string.
*
* @param string|null $three_d_secure The 3D Secure setting ('no-3d-secure', 'only-required-3d-secure', 'always-3d-secure').
* @return string The corresponding API enum string ('NO_3D_SECURE', 'SCA_WHEN_REQUIRED', 'SCA_ALWAYS').
*/
public function get_three_d_secure_enum( string $three_d_secure = null ): string {
// If no value is provided, use the current setting.
if ( $three_d_secure === null ) {
$three_d_secure = $this->get_three_d_secure();
}
$map = array(
'no-3d-secure' => 'NO_3D_SECURE',
'only-required-3d-secure' => 'SCA_WHEN_REQUIRED',
'always-3d-secure' => 'SCA_ALWAYS',
);
return $map[ $three_d_secure ] ?? 'SCA_WHEN_REQUIRED';
}
/**
* Sets the 3D Secure setting.
*

View file

@ -9,10 +9,14 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Endpoint;
use DomainException;
use Psr\Log\LoggerInterface;
use Exception;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXOGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -75,20 +79,34 @@ class ReturnUrlEndpoint {
* Handles the incoming request.
*/
public function handle_request(): void {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['token'] ) ) {
wc_add_notice( __( 'Payment session expired. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
try {
$order = $this->order_endpoint->order( $token );
} catch ( Exception $exception ) {
$this->logger->warning( "Return URL endpoint failed to fetch order $token: " . $exception->getMessage() );
wc_add_notice( __( 'Could not retrieve payment information. Please try again.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
$token = sanitize_text_field( wp_unslash( $_GET['token'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$order = $this->order_endpoint->order( $token );
if ( $order->status()->is( OrderStatus::APPROVED )
|| $order->status()->is( OrderStatus::COMPLETED )
) {
$this->session_handler->replace_order( $order );
// Handle 3DS completion if needed.
if ( $this->needs_3ds_completion( $order ) ) {
try {
$order = $this->complete_3ds_verification( $order );
} catch ( Exception $e ) {
$this->logger->warning( "3DS completion failed for order $token: " . $e->getMessage() );
wc_add_notice( $this->get_3ds_error_message( $e ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
}
$wc_order_id = (int) $order->purchase_units()[0]->custom_id();
@ -102,12 +120,17 @@ class ReturnUrlEndpoint {
}
$this->logger->warning( "Return URL endpoint $token: no WC order ID." );
wc_add_notice( __( 'Order information is missing. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
$wc_order = wc_get_order( $wc_order_id );
if ( ! is_a( $wc_order, \WC_Order::class ) ) {
$this->logger->warning( "Return URL endpoint $token: WC order $wc_order_id not found." );
wc_add_notice( __( 'Order not found. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
@ -117,7 +140,15 @@ class ReturnUrlEndpoint {
exit();
}
$success = $this->gateway->process_payment( $wc_order_id );
$payment_gateway = $this->get_payment_gateway( $wc_order->get_payment_method() );
if ( ! $payment_gateway ) {
wc_add_notice( __( 'Payment gateway is unavailable. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
$success = $payment_gateway->process_payment( $wc_order_id );
if ( isset( $success['result'] ) && 'success' === $success['result'] ) {
add_filter(
'allowed_redirect_hosts',
@ -130,7 +161,95 @@ class ReturnUrlEndpoint {
wp_safe_redirect( $success['redirect'] );
exit();
}
wc_add_notice( __( 'Payment processing failed. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' );
wp_safe_redirect( wc_get_checkout_url() );
exit();
}
/**
* Check if order needs 3DS completion.
*
* @param Order $order The PayPal order.
* @return bool
*/
private function needs_3ds_completion( Order $order ): bool {
// If order is still CREATED after 3DS redirect, it needs to be captured.
return $order->status()->is( OrderStatus::CREATED );
}
/**
* Complete 3DS verification by capturing the order.
*
* @param mixed $order The PayPal order.
* @return mixed The processed order.
* @throws Exception When 3DS completion fails.
* @throws RuntimeException When API errors occur that don't match decline patterns.
*/
private function complete_3ds_verification( $order ) {
try {
$captured_order = $this->order_endpoint->capture( $order );
// Check if capture actually succeeded vs. payment declined.
if ( $captured_order->status()->is( OrderStatus::COMPLETED ) ) {
return $captured_order;
} else {
// Capture API succeeded but payment was declined.
throw new Exception( __( 'Payment was declined by the payment provider. Please try a different payment method.', 'woocommerce-paypal-payments' ) );
}
} catch ( DomainException $e ) {
throw new Exception( __( '3D Secure authentication was unavailable or failed. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) );
} catch ( RuntimeException $e ) {
if ( strpos( $e->getMessage(), 'declined' ) !== false ||
strpos( $e->getMessage(), 'PAYMENT_DENIED' ) !== false ||
strpos( $e->getMessage(), 'INSTRUMENT_DECLINED' ) !== false ||
strpos( $e->getMessage(), 'Payment provider declined' ) !== false ) {
throw new Exception( __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' ) );
}
throw $e;
}
}
/**
* Get user-friendly error message for 3DS failures.
*
* @param Exception $exception The exception.
* @return string
*/
private function get_3ds_error_message( Exception $exception ): string {
$error_message = $exception->getMessage();
if ( strpos( $error_message, '3D Secure' ) !== false ) {
return $error_message;
}
if ( strpos( $error_message, 'declined' ) !== false ) {
return __( 'Your payment was declined after 3D Secure verification. Please try a different payment method or contact your bank.', 'woocommerce-paypal-payments' );
}
return __( 'There was an error processing your payment. Please try again or contact support.', 'woocommerce-paypal-payments' );
}
/**
* Gets the appropriate payment gateway for the given payment method.
*
* @param string $payment_method The payment method ID.
* @return \WC_Payment_Gateway|null
*/
private function get_payment_gateway( string $payment_method ) {
// For regular PayPal payments, use the injected gateway.
if ( $payment_method === $this->gateway->id ) {
return $this->gateway;
}
// For other payment methods (like AXO), get from WooCommerce.
$available_gateways = WC()->payment_gateways->get_available_payment_gateways();
if ( isset( $available_gateways[ $payment_method ] ) ) {
return $available_gateways[ $payment_method ];
}
return null;
}
}

View file

@ -13,6 +13,7 @@ use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\FraudProcessorResponse;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Factory\CardAuthenticationResultFactory;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
/**
@ -24,7 +25,6 @@ trait CreditCardOrderInfoHandlingTrait {
* Handles the 3DS details.
*
* Adds the order note with 3DS details.
* Adds the order meta with 3DS details.
*
* @param Order $order The PayPal order.
* @param WC_Order $wc_order The WC order.
@ -35,7 +35,7 @@ trait CreditCardOrderInfoHandlingTrait {
): void {
$payment_source = $order->payment_source();
if ( ! $payment_source || $payment_source->name() !== 'card' ) {
if ( ! $payment_source || ( $payment_source->name() !== 'card' && $payment_source->name() !== AxoGateway::ID ) ) {
return;
}

View file

@ -28,6 +28,7 @@ class OrderFactoryTest extends TestCase
$order->expects('create_time')->andReturn($createTime);
$order->expects('update_time')->andReturn($updateTime);
$order->expects('payment_source')->andReturnNull();
$order->expects('links')->andReturnNull();
$wcOrder = Mockery::mock(\WC_Order::class);
$purchaseUnitFactory = Mockery::mock(PurchaseUnitFactory::class);
$purchaseUnit = Mockery::mock(PurchaseUnit::class);
@ -89,6 +90,11 @@ class OrderFactoryTest extends TestCase
} else {
$this->assertEquals($orderData->update_time, $order->update_time()->format(\DateTime::ISO8601));
}
if ( isset($orderData->links) ) {
$this->assertEquals($orderData->links, $order->links());
} else {
$this->assertNull($order->links());
}
}
public function dataForTestFromPayPalResponseTest() : array
@ -135,6 +141,20 @@ class OrderFactoryTest extends TestCase
'update_time' => '2005-09-15T15:52:01+0000',
],
],
'with_links' => [
(object) [
'id' => 'id',
'purchase_units' => [new \stdClass(), new \stdClass()],
'status' => OrderStatus::PAYER_ACTION_REQUIRED,
'intent' => 'CAPTURE',
'create_time' => '2005-08-15T15:52:01+0000',
'update_time' => '2005-09-15T15:52:01+0000',
'payer' => new \stdClass(),
'links' => [
(object) ['rel' => 'payer-action', 'href' => 'https://example.com/3ds']
],
],
],
];
}
@ -181,13 +201,6 @@ class OrderFactoryTest extends TestCase
'intent' => '',
],
],
'no_status' => [
(object) [
'id' => '',
'purchase_units' => [],
'intent' => '',
],
],
'no_intent' => [
(object) [
'id' => '',