diff --git a/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js b/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js new file mode 100644 index 000000000..f94504d6d --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js @@ -0,0 +1,42 @@ +import { useCallback } from '@wordpress/element'; + +const useHandlePaymentSetup = ( + emitResponse, + card, + paymentComponent, + tokenizedCustomerData +) => { + return useCallback( async () => { + const isRyanFlow = !! card?.id; + let cardToken = card?.id; + + if ( ! cardToken && paymentComponent ) { + cardToken = await paymentComponent + .getPaymentToken( tokenizedCustomerData ) + .then( ( response ) => response.id ); + } + + if ( ! cardToken ) { + return { + type: emitResponse.responseTypes.ERROR, + message: 'Could not process the payment (tokenization error)', + }; + } + + return { + type: emitResponse.responseTypes.SUCCESS, + meta: { + paymentMethodData: { + fastlane_member: isRyanFlow, + axo_nonce: cardToken, + }, + }, + }; + }, [ + card, + paymentComponent, + tokenizedCustomerData, + ] ); +}; + +export default useHandlePaymentSetup; diff --git a/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js b/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js new file mode 100644 index 000000000..bc10e1533 --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js @@ -0,0 +1,24 @@ +import { useEffect, useCallback } from '@wordpress/element'; + +const usePaymentSetupEffect = ( onPaymentSetup, handlePaymentSetup ) => { + /** + * `onPaymentSetup()` fires when we enter the "PROCESSING" state in the checkout flow. + * It pre-processes the payment details and returns data for server-side processing. + */ + useEffect( () => { + const unsubscribe = onPaymentSetup( handlePaymentSetup ); + + return () => { + unsubscribe(); + }; + }, [ onPaymentSetup, handlePaymentSetup ] ); + + const handlePaymentLoad = useCallback( ( component ) => { + // We'll return this function instead of calling setPaymentComponent directly + return component; + }, [] ); + + return { handlePaymentLoad }; +}; + +export default usePaymentSetupEffect; diff --git a/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js b/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js new file mode 100644 index 000000000..868ddcb85 --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js @@ -0,0 +1,49 @@ +import { useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +export const useTokenizeCustomerData = () => { + const customerData = useSelect( ( select ) => + select( 'wc/store/cart' ).getCustomerData() + ); + + const isValidAddress = ( address ) => { + // At least one name must be present. + if ( ! address.first_name && ! address.last_name ) { + return false; + } + + // Street, city, postcode, country are mandatory; state is optional. + return ( + address.address_1 && + address.city && + address.postcode && + address.country + ); + }; + + // Memoize the customer data to avoid unnecessary re-renders (and potential infinite loops). + return useMemo( () => { + const { billingAddress, shippingAddress } = customerData; + + // Prefer billing address, but fallback to shipping address if billing address is not valid. + const mainAddress = isValidAddress( billingAddress ) + ? billingAddress + : shippingAddress; + + return { + cardholderName: { + fullName: `${ mainAddress.first_name } ${ mainAddress.last_name }`, + }, + billingAddress: { + addressLine1: mainAddress.address_1, + addressLine2: mainAddress.address_2, + adminArea1: mainAddress.state, + adminArea2: mainAddress.city, + postalCode: mainAddress.postcode, + countryCode: mainAddress.country, + }, + }; + }, [ customerData ] ); +}; + +export default useTokenizeCustomerData; diff --git a/modules/ppcp-axo-block/resources/js/index.js b/modules/ppcp-axo-block/resources/js/index.js index fa58222a3..26b2c188d 100644 --- a/modules/ppcp-axo-block/resources/js/index.js +++ b/modules/ppcp-axo-block/resources/js/index.js @@ -1,15 +1,18 @@ -import { useCallback, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { registerPaymentMethod } from '@woocommerce/blocks-registry'; // Hooks import useFastlaneSdk from './hooks/useFastlaneSdk'; +import useTokenizeCustomerData from './hooks/useTokenizeCustomerData'; import useCardChange from './hooks/useCardChange'; import useAxoSetup from './hooks/useAxoSetup'; import usePaymentSetup from './hooks/usePaymentSetup'; import useAxoCleanup from './hooks/useAxoCleanup'; +import useHandlePaymentSetup from './hooks/useHandlePaymentSetup'; // Components import { Payment } from './components/Payment/Payment'; +import usePaymentSetupEffect from './hooks/usePaymentSetupEffect'; const gatewayHandle = 'ppcp-axo-gateway'; const ppcpConfig = wc.wcSettings.getSetting( `${ gatewayHandle }_data` ); @@ -25,8 +28,17 @@ const Axo = ( props ) => { const { onPaymentSetup } = eventRegistration; const [ shippingAddress, setShippingAddress ] = useState( null ); const [ card, setCard ] = useState( null ); + const [ paymentComponent, setPaymentComponent ] = useState( null ); + const fastlaneSdk = useFastlaneSdk( axoConfig, ppcpConfig ); + const tokenizedCustomerData = useTokenizeCustomerData(); const onChangeCardButtonClick = useCardChange( fastlaneSdk, setCard ); + const handlePaymentSetup = useHandlePaymentSetup( + emitResponse, + card, + paymentComponent, + tokenizedCustomerData + ); useAxoSetup( ppcpConfig, @@ -36,11 +48,13 @@ const Axo = ( props ) => { setCard ); usePaymentSetup( onPaymentSetup, emitResponse, card ); - useAxoCleanup(); - const handlePaymentLoad = useCallback( ( paymentComponent ) => { - console.log( 'Payment component loaded', paymentComponent ); - }, [] ); + const { handlePaymentLoad } = usePaymentSetupEffect( + onPaymentSetup, + handlePaymentSetup + ); + + useAxoCleanup(); const handleCardChange = ( selectedCard ) => { console.log( 'Card selection changed', selectedCard ); diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index 0519e9209..3458f080e 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -75,6 +75,7 @@ return array( $container->get( 'wcgateway.settings.render' ), $container->get( 'wcgateway.settings' ), $container->get( 'wcgateway.url' ), + $container->get( 'session.handler' ), $container->get( 'wcgateway.order-processor' ), $container->get( 'axo.card_icons' ), $container->get( 'axo.card_icons.axo' ), diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 069860550..1ff7ae712 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -28,6 +28,8 @@ use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Helper\CartCheckoutDetector; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsListener; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; +use WC_Payment_Gateways; + /** * Class AxoModule */ @@ -130,6 +132,23 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { } ); + // Enforce Fastlane to always be the first payment method in the list. + add_action( + 'wc_payment_gateways_initialized', + function ( WC_Payment_Gateways $gateways ) { + if ( is_admin() ) { + return; + } + foreach ( $gateways->payment_gateways as $key => $gateway ) { + if ( $gateway->id === AxoGateway::ID ) { + unset( $gateways->payment_gateways[ $key ] ); + array_unshift( $gateways->payment_gateways, $gateway ); + break; + } + } + } + ); + // Force 'cart-block' and 'cart' Smart Button locations in the settings. add_action( 'admin_init', diff --git a/modules/ppcp-axo/src/Gateway/AxoGateway.php b/modules/ppcp-axo/src/Gateway/AxoGateway.php index 4fa80cd4a..dc54c0e9e 100644 --- a/modules/ppcp-axo/src/Gateway/AxoGateway.php +++ b/modules/ppcp-axo/src/Gateway/AxoGateway.php @@ -5,20 +5,20 @@ * @package WooCommerce\PayPalCommerce\WcGateway\Gateway */ -declare(strict_types=1); +declare( strict_types = 1 ); namespace WooCommerce\PayPalCommerce\Axo\Gateway; use Psr\Log\LoggerInterface; +use Exception; use WC_Order; use WC_Payment_Gateway; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\ApplicationContext; use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource; -use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; -use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewaySettingsRendererTrait; @@ -26,12 +26,15 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\TransactionUrlProvider; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderMetaTrait; use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; use WooCommerce\PayPalCommerce\WcGateway\Settings\SettingsRenderer; +use WooCommerce\PayPalCommerce\WcGateway\Gateway\ProcessPaymentTrait; +use WooCommerce\PayPalCommerce\WcGateway\Exception\GatewayGenericException; +use WooCommerce\PayPalCommerce\Session\SessionHandler; /** * Class AXOGateway. */ class AxoGateway extends WC_Payment_Gateway { - use OrderMetaTrait, GatewaySettingsRendererTrait; + use OrderMetaTrait, GatewaySettingsRendererTrait, ProcessPaymentTrait; const ID = 'ppcp-axo-gateway'; @@ -119,26 +122,35 @@ class AxoGateway extends WC_Payment_Gateway { */ protected $logger; + /** + * The Session Handler. + * + * @var SessionHandler + */ + protected $session_handler; + /** * AXOGateway constructor. * - * @param SettingsRenderer $settings_renderer The settings renderer. - * @param ContainerInterface $ppcp_settings The settings. - * @param string $wcgateway_module_url The WcGateway module URL. - * @param OrderProcessor $order_processor The Order processor. - * @param array $card_icons The card icons. - * @param array $card_icons_axo The card icons. - * @param OrderEndpoint $order_endpoint The order endpoint. - * @param PurchaseUnitFactory $purchase_unit_factory The purchase unit factory. - * @param ShippingPreferenceFactory $shipping_preference_factory The shipping preference factory. - * @param TransactionUrlProvider $transaction_url_provider The transaction url provider. - * @param Environment $environment The environment. - * @param LoggerInterface $logger The logger. + * @param SettingsRenderer $settings_renderer The settings renderer. + * @param ContainerInterface $ppcp_settings The settings. + * @param string $wcgateway_module_url The WcGateway module URL. + * @param SessionHandler $session_handler The session handler. + * @param OrderProcessor $order_processor The Order processor. + * @param array $card_icons The card icons. + * @param array $card_icons_axo The card icons. + * @param OrderEndpoint $order_endpoint The order endpoint. + * @param PurchaseUnitFactory $purchase_unit_factory The purchase unit factory. + * @param ShippingPreferenceFactory $shipping_preference_factory Shipping preference factory. + * @param TransactionUrlProvider $transaction_url_provider The transaction url provider. + * @param Environment $environment The environment. + * @param LoggerInterface $logger The logger. */ public function __construct( SettingsRenderer $settings_renderer, ContainerInterface $ppcp_settings, string $wcgateway_module_url, + SessionHandler $session_handler, OrderProcessor $order_processor, array $card_icons, array $card_icons_axo, @@ -154,6 +166,7 @@ class AxoGateway extends WC_Payment_Gateway { $this->settings_renderer = $settings_renderer; $this->ppcp_settings = $ppcp_settings; $this->wcgateway_module_url = $wcgateway_module_url; + $this->session_handler = $session_handler; $this->order_processor = $order_processor; $this->card_icons = $card_icons; $this->card_icons_axo = $card_icons_axo; @@ -213,80 +226,83 @@ class AxoGateway extends WC_Payment_Gateway { * Processes the order. * * @param int $order_id The WC order ID. + * * @return array */ public function process_payment( $order_id ) { $wc_order = wc_get_order( $order_id ); - // phpcs:ignore WordPress.Security.NonceVerification.Missing - $fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) ); - if ( $fastlane_member ) { - $payment_method_title = __( 'Debit & Credit Cards (via Fastlane by PayPal)', 'woocommerce-paypal-payments' ); - $wc_order->set_payment_method_title( $payment_method_title ); - $wc_order->save(); + if ( ! is_a( $wc_order, WC_Order::class ) ) { + return $this->handle_payment_failure( + null, + new GatewayGenericException( new Exception( 'WC order was not found.' ) ) + ); } - $purchase_unit = $this->purchase_unit_factory->from_wc_order( $wc_order ); - - // phpcs:ignore WordPress.Security.NonceVerification.Missing - $nonce = wc_clean( wp_unslash( $_POST['axo_nonce'] ?? '' ) ); - try { - $shipping_preference = $this->shipping_preference_factory->from_state( - $purchase_unit, - 'checkout' - ); - - $payment_source_properties = new \stdClass(); - $payment_source_properties->single_use_token = $nonce; - - $payment_source = new PaymentSource( - 'card', - $payment_source_properties - ); - - $order = $this->order_endpoint->create( - array( $purchase_unit ), - $shipping_preference, - null, - null, - '', - ApplicationContext::USER_ACTION_CONTINUE, - '', - array(), - $payment_source - ); - - $this->order_processor->process_captured_and_authorized( $wc_order, $order ); - - } catch ( RuntimeException $exception ) { - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $fastlane_member = wc_clean( wp_unslash( $_POST['fastlane_member'] ?? '' ) ); + if ( $fastlane_member ) { + $payment_method_title = __( 'Debit & Credit Cards (via Fastlane by PayPal)', 'woocommerce-paypal-payments' ); + $wc_order->set_payment_method_title( $payment_method_title ); + $wc_order->save(); } - $this->logger->error( $error ); - wc_add_notice( $error, 'error' ); + // 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'] ?? '' ) ); - $wc_order->update_status( - 'failed', - $error - ); + $order = $this->create_paypal_order( $wc_order, $token ); - return array( - 'result' => 'failure', - 'redirect' => wc_get_checkout_url(), - ); + $this->order_processor->process_captured_and_authorized( $wc_order, $order ); + } catch ( Exception $exception ) { + return $this->handle_payment_failure( $wc_order, $exception ); } WC()->cart->empty_cart(); - $result = array( + return array( 'result' => 'success', 'redirect' => $this->get_return_url( $wc_order ), ); + } - return $result; + /** + * Create a new PayPal order from the existing WC_Order instance. + * + * @param WC_Order $wc_order The WooCommerce order to use as a base. + * @param string $payment_token The payment token, generated by the JS SDK. + * + * @return Order The PayPal order. + */ + protected function create_paypal_order( WC_Order $wc_order, string $payment_token ) : Order { + $purchase_unit = $this->purchase_unit_factory->from_wc_order( $wc_order ); + + $shipping_preference = $this->shipping_preference_factory->from_state( + $purchase_unit, + 'checkout' + ); + + $payment_source_properties = (object) array( + 'single_use_token' => $payment_token, + ); + + $payment_source = new PaymentSource( + 'card', + $payment_source_properties + ); + + return $this->order_endpoint->create( + array( $purchase_unit ), + $shipping_preference, + null, + null, + '', + ApplicationContext::USER_ACTION_CONTINUE, + '', + array(), + $payment_source + ); } /** @@ -328,7 +344,7 @@ class AxoGateway extends WC_Payment_Gateway { * * @return string */ - public function get_transaction_url( $order ): string { + public function get_transaction_url( $order ) : string { $this->view_transaction_url = $this->transaction_url_provider->get_transaction_url_base( $order ); return parent::get_transaction_url( $order ); @@ -362,7 +378,7 @@ class AxoGateway extends WC_Payment_Gateway { * * @return SettingsRenderer */ - protected function settings_renderer(): SettingsRenderer { + protected function settings_renderer() : SettingsRenderer { return $this->settings_renderer; } } diff --git a/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php b/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php index 3cf401b29..ed940db78 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php +++ b/modules/ppcp-wc-gateway/src/Gateway/ProcessPaymentTrait.php @@ -13,6 +13,7 @@ use Exception; use Throwable; use WC_Order; use WooCommerce\PayPalCommerce\WcGateway\Exception\GatewayGenericException; +use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; /** * Trait ProcessPaymentTrait @@ -74,8 +75,13 @@ trait ProcessPaymentTrait { * @param Throwable $exception The exception to format. * @return string */ - protected function format_exception( Throwable $exception ): string { - $output = $exception->getMessage() . ' ' . basename( $exception->getFile() ) . ':' . $exception->getLine(); + protected function format_exception( Throwable $exception ) : string { + $message = $exception->getMessage(); + if ( is_a( $exception, PayPalApiException::class ) ) { + $message = $exception->get_details( $message ); + } + + $output = $message . ' ' . basename( $exception->getFile() ) . ':' . $exception->getLine(); $prev = $exception->getPrevious(); if ( ! $prev ) { return $output;