diff --git a/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php b/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php index cba03352f7..d80f8094d8 100644 --- a/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php +++ b/modules/ppcp-api-client/src/Factory/ReturnUrlFactory.php @@ -1,9 +1,4 @@ $request_data The request parameters, if exist. + * 'order_id`, 'purchase_units' etc. + * @param array $custom_query_args Additional query args to add into the URL. + * * @throws RuntimeException When required data is missing for the context. */ - public function from_context( string $context, array $request_data = array() ): string { + public function from_context( + string $context, + array $request_data = array(), + array $custom_query_args = array() + ): string { + return add_query_arg( + array_merge( + array( self::PCP_QUERY_ARG => 'button' ), + $custom_query_args + ), + $this->wc_url_from_context( $context, $request_data ) + ); + } + + protected function wc_url_from_context( string $context, array $request_data = array() ): string { switch ( $context ) { case 'cart': case 'cart-block': diff --git a/modules/ppcp-blocks/resources/js/Components/paypal.js b/modules/ppcp-blocks/resources/js/Components/paypal.js index 92287381fc..f2185f0b72 100644 --- a/modules/ppcp-blocks/resources/js/Components/paypal.js +++ b/modules/ppcp-blocks/resources/js/Components/paypal.js @@ -21,6 +21,7 @@ import { onApproveSavePayment, } from '../paypal-config'; import { useRef } from 'react'; +import Spinner from '../../../../ppcp-button/resources/js/modules/Helper/Spinner'; const PAYPAL_GATEWAY_ID = 'ppcp-gateway'; @@ -52,8 +53,11 @@ export const PayPalComponent = ( { useState( false ); const [ paypalScriptLoaded, setPaypalScriptLoaded ] = useState( false ); + const [ isFullPageSpinnerActive, setIsFullPageSpinnerActive ] = + useState( false ); const paypalButtonRef = useRef( null ); + const spinnerRef = useRef( null ); if ( ! paypalScriptLoaded ) { if ( ! paypalScriptPromise ) { @@ -70,6 +74,18 @@ export const PayPalComponent = ( { ? `${ config.id }-${ fundingSource }` : config.id; + // Full-page spinner used to block UI interactions during flows like AppSwitch. + useEffect( () => { + if ( isFullPageSpinnerActive ) { + if ( ! spinnerRef.current ) { + spinnerRef.current = Spinner.fullPage(); + } + spinnerRef.current.block(); + } else if ( spinnerRef.current ) { + spinnerRef.current.unblock(); + } + }, [ isFullPageSpinnerActive ] ); + useEffect( () => { // fill the form if in continuation (for product or mini-cart buttons) if ( continuationFilled || ! config.scriptData.continuation?.order ) { @@ -286,6 +302,14 @@ export const PayPalComponent = ( { }; }, [ onPaymentSetup, paypalOrder, activePaymentMethod ] ); + useEffect( () => { + const unsubscribe = onCheckoutFail( () => { + setIsFullPageSpinnerActive( false ); + } ); + + return unsubscribe; + }, [ onCheckoutFail ] ); + useEffect( () => { if ( activePaymentMethod !== methodId ) { return; @@ -336,7 +360,8 @@ export const PayPalComponent = ( { setGotoContinuationOnError, onSubmit, onError, - onClose + onClose, + setIsFullPageSpinnerActive ); }, } @@ -491,7 +516,8 @@ export const PayPalComponent = ( { setGotoContinuationOnError, onSubmit, onError, - onClose + onClose, + setIsFullPageSpinnerActive ) } onShippingOptionsChange={ getOnShippingOptionsChange( diff --git a/modules/ppcp-blocks/resources/js/paypal-config.js b/modules/ppcp-blocks/resources/js/paypal-config.js index 4aacab3180..93105786fe 100644 --- a/modules/ppcp-blocks/resources/js/paypal-config.js +++ b/modules/ppcp-blocks/resources/js/paypal-config.js @@ -64,8 +64,11 @@ export const handleApprove = async ( setGotoContinuationOnError, onSubmit, onError, - onClose + onClose, + setIsFullPageSpinnerActive ) => { + setIsFullPageSpinnerActive( true ); + try { let order; @@ -168,6 +171,8 @@ export const handleApprove = async ( } catch ( err ) { console.error( err ); + setIsFullPageSpinnerActive( false ); + onError( err.message ); onClose(); diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstrap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstrap.js index ef51b32d5a..87dadb4686 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstrap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstrap.js @@ -8,6 +8,7 @@ import SimulateCart from '../Helper/SimulateCart'; import { strRemoveWord, strAddWord, throttle } from '../Helper/Utils'; import merge from 'deepmerge'; import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce'; +import ResumeFlowHelper from '../Helper/ResumeFlowHelper'; class SingleProductBootstrap { constructor( gateway, renderer, errorHandler ) { @@ -53,7 +54,10 @@ class SingleProductBootstrap { return; } - this.render(); + // Avoid re-rendering during the resume flow to prevent duplicate onApprove callbacks. + if ( ! ResumeFlowHelper.isResumeFlow() ) { + this.render(); + } this.renderer.enableSmartButtons( this.gateway.button.wrapper ); show( this.gateway.button.wrapper ); diff --git a/modules/ppcp-button/resources/js/modules/Helper/Spinner.js b/modules/ppcp-button/resources/js/modules/Helper/Spinner.js index 30d0830f90..63f9412792 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/Spinner.js +++ b/modules/ppcp-button/resources/js/modules/Helper/Spinner.js @@ -14,12 +14,16 @@ class Spinner { background: '#fff', opacity: 0.6, }, + baseZ: 10000, } ); } unblock() { jQuery( this.target ).unblock(); } -} + static fullPage() { + return new Spinner( window ); + } +} export default Spinner; diff --git a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js index 13b914335d..d5c5ab6dcb 100644 --- a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js +++ b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js @@ -1,3 +1,5 @@ +import Spinner from '../Helper/Spinner'; + const initiateRedirect = ( successUrl ) => { /** * Notice how this step initiates a redirect to a new page using a plain @@ -15,6 +17,10 @@ const initiateRedirect = ( successUrl ) => { const onApprove = ( context, errorHandler ) => { return ( data, actions ) => { + // Block the entire page during approval process + const spinner = Spinner.fullPage(); + spinner.block(); + const canCreateOrder = ! context.config.vaultingEnabled || data.paymentSource !== 'venmo'; @@ -50,6 +56,9 @@ const onApprove = ( context, errorHandler ) => { const orderReceivedUrl = approveData.data?.order_received_url; initiateRedirect( orderReceivedUrl || context.config.redirect ); + } ) + .finally( () => { + spinner.unblock(); } ); }; }; diff --git a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForPayNow.js b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForPayNow.js index 629e8871aa..b64f89f201 100644 --- a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForPayNow.js +++ b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForPayNow.js @@ -1,3 +1,8 @@ +import { + getCurrentPaymentMethod, + PaymentMethods, +} from '../Helper/CheckoutMethodState'; + const onApprove = ( context, errorHandler, spinner ) => { return ( data, actions ) => { spinner.block(); @@ -34,6 +39,15 @@ const onApprove = ( context, errorHandler, spinner ) => { } throw new Error( data.data.message ); } + + // in some cases a different method may get selected, + // such as when returning from AppSwitch in a different browser and PayPal is not default + if ( ! getCurrentPaymentMethod().startsWith( 'ppcp-' ) ) { + jQuery( + `input[name="payment_method"][value="${ PaymentMethods.PAYPAL }"]` + ).prop( 'checked', true ); + } + document.querySelector( '#place_order' ).click(); } ); }; diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 0f02028ef0..da9a073912 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -18,6 +18,8 @@ use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; use WooCommerce\PayPalCommerce\Button\Helper\DisabledFundingSources; use WooCommerce\PayPalCommerce\Button\Helper\WooCommerceOrderCreator; +use WooCommerce\PayPalCommerce\Button\Session\CartDataFactory; +use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage; use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Session\SessionHandler; @@ -236,6 +238,8 @@ public function get_context(): string { $session_handler, $settings, $early_order_handler, + $container->get( 'button.session.factory.card-data' ), + $container->get( 'button.session.storage.card-data.transient' ), $registration_needed, $container->get( 'wcgateway.settings.card_billing_data_mode' ), $container->get( 'button.early-wc-checkout-validation-enabled' ), @@ -407,7 +411,15 @@ public function get_context(): string { return new WooCommerceOrderCreator( $container->get( 'wcgateway.funding-source.renderer' ), $container->get( 'session.handler' ), - $container->get( 'wc-subscriptions.helper' ) + $container->get( 'wc-subscriptions.helper' ), + $container->get( 'button.session.factory.card-data' ) ); }, + + 'button.session.factory.card-data' => static function ( ContainerInterface $container ): CartDataFactory { + return new CartDataFactory(); + }, + 'button.session.storage.card-data.transient' => static function ( ContainerInterface $container ): CartDataTransientStorage { + return new CartDataTransientStorage(); + }, ); diff --git a/modules/ppcp-button/src/ButtonModule.php b/modules/ppcp-button/src/ButtonModule.php index cb3110329c..fbbe3e3884 100644 --- a/modules/ppcp-button/src/ButtonModule.php +++ b/modules/ppcp-button/src/ButtonModule.php @@ -9,6 +9,8 @@ namespace WooCommerce\PayPalCommerce\Button; +use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; +use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveSubscriptionEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CartScriptParamsEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; @@ -22,6 +24,8 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\GetOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; +use WooCommerce\PayPalCommerce\Button\Helper\WooCommerceOrderCreator; +use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; @@ -98,6 +102,8 @@ static function ( $value ) use ( $c ) { $this->register_ajax_endpoints( $c ); + $this->register_appswitch_crossbrowser_handler( $c ); + return true; } @@ -228,4 +234,75 @@ static function () use ( $container ) { } ); } + + private function register_appswitch_crossbrowser_handler( ContainerInterface $container ): void { + if ( ! $container->get( 'wcgateway.appswitch-enabled' ) ) { + return; + } + + // After returning from cross-browser AppSwitch (started in non-default browser, then redirected to the default one) + // we need to retrieve the saved cart and PayPal order, create a WC order and redirect to Pay for order. + add_action( + 'wp', + static function () use ( $container ) { + // phpcs:ignore WordPress.Security.NonceVerification + if ( ! isset( $_GET[ ReturnUrlFactory::PCP_QUERY_ARG ] ) ) { + return; + } + + if ( is_checkout_pay_page() ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification + if ( ! isset( $_GET[ CreateOrderEndpoint::RETURN_URL_CART_QUERY_ARG ] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification + $cart_key = wc_clean( wp_unslash( $_GET[ CreateOrderEndpoint::RETURN_URL_CART_QUERY_ARG ] ) ); + if ( ! is_string( $cart_key ) ) { + return; + } + + $card_data_storage = $container->get( 'button.session.storage.card-data.transient' ); + assert( $card_data_storage instanceof CartDataTransientStorage ); + + $cart_data = $card_data_storage->get( $cart_key ); + if ( ! $cart_data ) { + return; + } + + // Delete the data to avoid accidentally triggering it again, duplicating orders etc. + $card_data_storage->remove( $cart_data ); + + if ( ! WC()->cart ) { + return; + } + // The current cart is the same, so we don't need to do anything (probably not cross-browser). + if ( WC()->cart->get_cart_hash() === $cart_data->cart_hash() ) { + return; + } + + $paypal_order_id = $cart_data->paypal_order_id(); + if ( empty( $paypal_order_id ) ) { + return; + } + + $order_endpoint = $container->get( 'api.endpoint.order' ); + assert( $order_endpoint instanceof OrderEndpoint ); + + $paypal_order = $order_endpoint->order( $paypal_order_id ); + + $wc_order_creator = $container->get( 'button.helper.wc-order-creator' ); + assert( $wc_order_creator instanceof WooCommerceOrderCreator ); + + $wc_order = $wc_order_creator->create_from_paypal_order( $paypal_order, $cart_data ); + + // Redirect via JS because we need to keep the # parameters which are not accessible on the server side. + // phpcs:ignore WordPress.Security.EscapeOutput + echo ""; + } + ); + } } diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index 818c22a4b8..a409350734 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -30,6 +30,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; use WooCommerce\PayPalCommerce\Button\Exception\ValidationException; +use WooCommerce\PayPalCommerce\Button\Session\CartDataFactory; +use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage; use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; use WooCommerce\PayPalCommerce\Session\SessionHandler; @@ -50,6 +52,8 @@ class CreateOrderEndpoint implements EndpointInterface { const ENDPOINT = 'ppc-create-order'; + public const RETURN_URL_CART_QUERY_ARG = 'pcp-cart'; + /** * The request data helper. * @@ -118,6 +122,10 @@ class CreateOrderEndpoint implements EndpointInterface { */ private $early_order_handler; + protected CartDataFactory $cart_data_factory; + + protected CartDataTransientStorage $cart_data_transient_storage; + /** * Data from the request. * @@ -207,6 +215,8 @@ class CreateOrderEndpoint implements EndpointInterface { * @param SessionHandler $session_handler The SessionHandler object. * @param Settings $settings The Settings object. * @param EarlyOrderHandler $early_order_handler The EarlyOrderHandler object. + * @param CartDataFactory $cart_data_factory + * @param CartDataTransientStorage $cart_data_transient_storage * @param bool $registration_needed Whether a new user must be registered during checkout. * @param string $card_billing_data_mode The value of card_billing_data_mode from the settings. * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. @@ -228,6 +238,8 @@ public function __construct( SessionHandler $session_handler, Settings $settings, EarlyOrderHandler $early_order_handler, + CartDataFactory $cart_data_factory, + CartDataTransientStorage $cart_data_transient_storage, bool $registration_needed, string $card_billing_data_mode, bool $early_validation_enabled, @@ -249,6 +261,8 @@ public function __construct( $this->session_handler = $session_handler; $this->settings = $settings; $this->early_order_handler = $early_order_handler; + $this->cart_data_factory = $cart_data_factory; + $this->cart_data_transient_storage = $cart_data_transient_storage; $this->registration_needed = $registration_needed; $this->card_billing_data_mode = $card_billing_data_mode; $this->early_validation_enabled = $early_validation_enabled; @@ -475,33 +489,52 @@ private function create_paypal_order( ?WC_Order $wc_order = null, string $paymen $payment_source_key ); + $custom_args = array(); + + $cart_data = null; + // Save the cart to be able to access it after cross-browser AppSwitch. + if ( 'pay-now' !== $data['context'] ) { + try { + $cart_data = $this->cart_data_factory->from_current_cart(); + $cart_data->generate_key(); + + $key = $cart_data->key(); + assert( ! empty( $key ) ); + + $custom_args[ self::RETURN_URL_CART_QUERY_ARG ] = $key; + } catch ( Throwable $exception ) { + $this->logger->error( 'Failed to serialize cart: ' . $exception->getMessage() ); + } + } + + $return_url = $this->return_url_factory->from_context( + $this->parsed_request_data['context'], + $this->parsed_request_data, + $custom_args + ); + $experience_context = $this->experience_context_builder ->with_default_paypal_config( $shipping_preference, $action ) - ->with_contact_preference( $contact_preference ); + ->with_contact_preference( $contact_preference ) + ->with_custom_return_url( $return_url ) + ->with_custom_cancel_url( $return_url ); if ( $this->server_side_shipping_callback_enabled && $shipping_preference === ExperienceContext::SHIPPING_PREFERENCE_GET_FROM_FILE ) { $experience_context = $experience_context->with_shipping_callback(); } - $return_url = $this->return_url_factory->from_context( - $this->parsed_request_data['context'], - $this->parsed_request_data - ); - $payment_source = new PaymentSource( $payment_source_key, (object) array( 'experience_context' => $experience_context - ->with_custom_return_url( $return_url ) - ->with_custom_cancel_url( $return_url ) ->build() ->to_array(), ) ); try { - return $this->api_endpoint->create( + $order = $this->api_endpoint->create( array( $this->purchase_unit ), $shipping_preference, $payer, @@ -524,7 +557,7 @@ function ( stdClass $detail ): bool { $this->purchase_unit->set_shipping( null ); - return $this->api_endpoint->create( + $order = $this->api_endpoint->create( array( $this->purchase_unit ), $shipping_preference, $payer, @@ -532,10 +565,21 @@ function ( stdClass $detail ): bool { $data, $payment_source ); + } else { + throw $exception; } + } - throw $exception; + if ( $cart_data ) { + try { + $cart_data->set_paypal_order_id( $order->id() ); + $this->cart_data_transient_storage->save( $cart_data ); + } catch ( Throwable $exception ) { + $this->logger->error( 'Failed to save cart: ' . $exception->getMessage() ); + } } + + return $order; } /** diff --git a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php index 052afe46d3..10b7905673 100644 --- a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php +++ b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php @@ -24,6 +24,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Entity\Order; use WooCommerce\PayPalCommerce\ApiClient\Entity\Payer; use WooCommerce\PayPalCommerce\ApiClient\Entity\Shipping; +use WooCommerce\PayPalCommerce\Button\Session\CartData; +use WooCommerce\PayPalCommerce\Button\Session\CartDataFactory; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\WcGateway\FundingSource\FundingSourceRenderer; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; @@ -56,32 +58,33 @@ class WooCommerceOrderCreator { */ protected $subscription_helper; - /** - * WooCommerceOrderCreator constructor. - * - * @param FundingSourceRenderer $funding_source_renderer The funding source renderer. - * @param SessionHandler $session_handler The session handler. - * @param SubscriptionHelper $subscription_helper The subscription helper. - */ + protected CartDataFactory $cart_data_factory; + public function __construct( FundingSourceRenderer $funding_source_renderer, SessionHandler $session_handler, - SubscriptionHelper $subscription_helper + SubscriptionHelper $subscription_helper, + CartDataFactory $cart_data_factory ) { $this->funding_source_renderer = $funding_source_renderer; $this->session_handler = $session_handler; $this->subscription_helper = $subscription_helper; + $this->cart_data_factory = $cart_data_factory; } /** * Creates WC order based on given PayPal order. * - * @param Order $order The PayPal order. - * @param WC_Cart $wc_cart The Cart. - * @return WC_Order The WC order. + * @param Order $order The PayPal order. + * @param WC_Cart|CartData $cart The WC cart (converted into CartData). * @throws RuntimeException If problem creating. */ - public function create_from_paypal_order( Order $order, WC_Cart $wc_cart ): WC_Order { + public function create_from_paypal_order( Order $order, $cart ): WC_Order { + $cart_data = $cart; + if ( $cart_data instanceof WC_Cart ) { + $cart_data = $this->cart_data_factory->from_current_cart( $cart_data ); + } + $wc_order = wc_create_order(); if ( ! $wc_order instanceof WC_Order ) { @@ -95,9 +98,9 @@ public function create_from_paypal_order( Order $order, WC_Cart $wc_cart ): WC_O $this->configure_payment_source( $wc_order ); $this->configure_customer( $wc_order ); - $this->configure_line_items( $wc_order, $wc_cart, $payer, $shipping ); - $this->configure_addresses( $wc_order, $payer, $shipping, $wc_cart ); - $this->configure_coupons( $wc_order, $wc_cart->get_applied_coupons() ); + $this->configure_line_items( $wc_order, $cart_data, $payer, $shipping ); + $this->configure_addresses( $wc_order, $payer, $shipping, $cart_data->needs_shipping() ); + $this->configure_coupons( $wc_order, $cart_data->coupons() ); $wc_order->calculate_totals(); $wc_order->save(); @@ -106,7 +109,7 @@ public function create_from_paypal_order( Order $order, WC_Cart $wc_cart ): WC_O throw new RuntimeException( 'Failed to create WooCommerce order: ' . $exception->getMessage() ); } - do_action( 'woocommerce_paypal_payments_shipping_callback_woocommerce_order_created', $wc_order, $wc_cart ); + do_action( 'woocommerce_paypal_payments_woocommerce_order_created_from_cart', $wc_order, $cart_data ); return $wc_order; } @@ -114,17 +117,10 @@ public function create_from_paypal_order( Order $order, WC_Cart $wc_cart ): WC_O /** * Configures the line items. * - * @param WC_Order $wc_order The WC order. - * @param WC_Cart $wc_cart The Cart. - * @param Payer|null $payer The payer. - * @param Shipping|null $shipping The shipping. - * @return void * @psalm-suppress InvalidScalarArgument */ - protected function configure_line_items( WC_Order $wc_order, WC_Cart $wc_cart, ?Payer $payer, ?Shipping $shipping ): void { - $cart_contents = $wc_cart->get_cart(); - - foreach ( $cart_contents as $cart_item ) { + protected function configure_line_items( WC_Order $wc_order, CartData $cart_data, ?Payer $payer, ?Shipping $shipping ): void { + foreach ( $cart_data->items() as $cart_item ) { $product_id = $cart_item['product_id'] ?? 0; $variation_id = $cart_item['variation_id'] ?? 0; $quantity = $cart_item['quantity'] ?? 0; @@ -173,9 +169,9 @@ protected function configure_line_items( WC_Order $wc_order, WC_Cart $wc_cart, ? $item->set_total( $subscription_total ); $subscription->add_product( $product ); - $this->configure_addresses( $subscription, $payer, $shipping, $wc_cart ); + $this->configure_addresses( $subscription, $payer, $shipping, $cart_data->needs_shipping() ); $this->configure_payment_source( $subscription ); - $this->configure_coupons( $subscription, $wc_cart->get_applied_coupons() ); + $this->configure_coupons( $subscription, $cart_data->coupons() ); $dates = array( 'trial_end' => WC_Subscriptions_Product::get_trial_expiration_date( $product_id ), @@ -195,15 +191,10 @@ protected function configure_line_items( WC_Order $wc_order, WC_Cart $wc_cart, ? /** * Configures the shipping & billing addresses for WC order from given payer. * - * @param WC_Order $wc_order The WC order. - * @param Payer|null $payer The payer. - * @param Shipping|null $shipping The shipping. - * @param WC_Cart $wc_cart The Cart. - * @return void * @throws WC_Data_Exception|RuntimeException When failing to configure shipping. * @psalm-suppress RedundantConditionGivenDocblockType */ - protected function configure_addresses( WC_Order $wc_order, ?Payer $payer, ?Shipping $shipping, WC_Cart $wc_cart ): void { + protected function configure_addresses( WC_Order $wc_order, ?Payer $payer, ?Shipping $shipping, bool $needs_shipping ): void { $shipping_address = null; $billing_address = null; $shipping_options = null; @@ -253,7 +244,7 @@ protected function configure_addresses( WC_Order $wc_order, ?Payer $payer, ?Ship $shipping_options = $shipping->options()[0] ?? ''; } - if ( $wc_cart->needs_shipping() && empty( $shipping_options ) ) { + if ( $needs_shipping && empty( $shipping_options ) ) { throw new RuntimeException( 'No shipping method has been selected.' ); } diff --git a/modules/ppcp-button/src/Session/CartData.php b/modules/ppcp-button/src/Session/CartData.php new file mode 100644 index 0000000000..8f5ad1b66e --- /dev/null +++ b/modules/ppcp-button/src/Session/CartData.php @@ -0,0 +1,114 @@ +> + */ + protected array $items; + + /** + * @var string[] + */ + protected array $coupons; + + protected bool $needs_shipping; + + protected string $cart_hash; + + protected ?string $paypal_order_id = null; + + /** + * @param array> $items The cart items like in $cart->get_cart_for_session() or $cart->get_cart(). + * @param string[] $coupons + * @param bool $needs_shipping + * @param string $cart_hash + */ + public function __construct( + array $items, + array $coupons, + bool $needs_shipping, + string $cart_hash + ) { + $this->items = $items; + $this->coupons = $coupons; + $this->needs_shipping = $needs_shipping; + $this->cart_hash = $cart_hash; + } + + /** + * Generates a new random key. + */ + public function generate_key(): void { + $this->key = uniqid( '', true ); + } + + /** + * Returns the key that can be used for identifying the instance in storage. + */ + public function key(): ?string { + return $this->key; + } + + /** + * The cart items like in $cart->get_cart_for_session() or $cart->get_cart(). + * + * @return array> + */ + public function items(): array { + return $this->items; + } + + /** + * @return string[] + */ + public function coupons(): array { + return $this->coupons; + } + + public function needs_shipping(): bool { + return $this->needs_shipping; + } + + public function cart_hash(): string { + return $this->cart_hash; + } + + public function set_paypal_order_id( ?string $paypal_order_id ): void { + $this->paypal_order_id = $paypal_order_id; + } + + public function paypal_order_id(): ?string { + return $this->paypal_order_id; + } + + public function to_array(): array { + return array( + 'items' => $this->items, + 'coupons' => $this->coupons, + 'needs_shipping' => $this->needs_shipping, + 'cart_hash' => $this->cart_hash, + 'paypal_order_id' => $this->paypal_order_id, + ); + } + + public static function from_array( array $data, ?string $key = null ): CartData { + $cart_data = new CartData( + $data['items'] ?? array(), + $data['coupons'] ?? array(), + (bool) ( $data['needs_shipping'] ?? false ), + $data['cart_hash'] ?? '' + ); + $cart_data->paypal_order_id = $data['paypal_order_id'] ?? null; + $cart_data->key = $key; + return $cart_data; + } +} diff --git a/modules/ppcp-button/src/Session/CartDataFactory.php b/modules/ppcp-button/src/Session/CartDataFactory.php new file mode 100644 index 0000000000..de5ef6b6fe --- /dev/null +++ b/modules/ppcp-button/src/Session/CartDataFactory.php @@ -0,0 +1,34 @@ +cart; + if ( ! $cart instanceof WC_Cart ) { + throw new Exception( 'Cart not found.' ); + } + } + + return new CartData( + $cart->get_cart_for_session(), + $cart->get_applied_coupons(), + $cart->needs_shipping(), + $cart->get_cart_hash() + ); + } +} diff --git a/modules/ppcp-button/src/Session/CartDataTransientStorage.php b/modules/ppcp-button/src/Session/CartDataTransientStorage.php new file mode 100644 index 0000000000..ca1c202c44 --- /dev/null +++ b/modules/ppcp-button/src/Session/CartDataTransientStorage.php @@ -0,0 +1,47 @@ +key() ) ) { + $cart_data->generate_key(); + } + + $key = $cart_data->key(); + assert( ! empty( $key ) ); + + if ( ! set_transient( $key, $cart_data->to_array(), $this->expiration ) ) { + throw new Exception( 'set_transient failed.' ); + } + } + + public function get( string $key ): ?CartData { + $data = get_transient( $key ); + if ( ! is_array( $data ) ) { + return null; + } + + return CartData::from_array( $data, $key ); + } + + public function remove( CartData $cart_data ): void { + $key = $cart_data->key(); + if ( empty( $key ) ) { + return; + } + delete_transient( $key ); + } +} diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index 56f784f04f..83cf7075d7 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -13,9 +13,10 @@ use WC_Cart; use WC_Order; use WC_Order_Item_Product; +use WooCommerce\PayPalCommerce\Button\Session\CartData; use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply; -use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel; use WooCommerce\PayPalCommerce\Settings\SettingsModule; +use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; @@ -537,11 +538,10 @@ static function( string $total, array $cart_item ) { */ protected function initialize_wc_bookings_compat_layer( ContainerInterface $container ): void { add_action( - 'woocommerce_paypal_payments_shipping_callback_woocommerce_order_created', - static function ( WC_Order $wc_order, WC_Cart $wc_cart ) use ( $container ): void { + 'woocommerce_paypal_payments_woocommerce_order_created_from_cart', + static function ( WC_Order $wc_order, CartData $cart_data ) use ( $container ): void { try { - $cart_contents = $wc_cart->get_cart(); - foreach ( $cart_contents as $cart_item ) { + foreach ( $cart_data->items() as $cart_item ) { if ( empty( $cart_item['booking'] ) ) { continue; } diff --git a/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php b/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php index 734856357c..39ff7baaf9 100644 --- a/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php +++ b/tests/PHPUnit/ApiClient/Factory/ReturnUrlFactoryTest.php @@ -16,6 +16,16 @@ public function setUp(): void { parent::setUp(); $this->testee = new ReturnUrlFactory(); + + when('add_query_arg')->alias(function (array $args, string $url): string { + $query_parts = []; + foreach ($args as $key => $value) { + $query_parts[] = $key . '=' . $value; + } + return $url . + (strpos($url, '?') === false ? '?' : '&') . + implode('&', $query_parts); + }); } /** @@ -27,7 +37,7 @@ public function testFromContextReturnsCartUrl(string $context) $result = $this->testee->from_context($context); - $this->assertEquals('https://example.com/cart', $result); + $this->assertEquals('https://example.com/cart?pcp-return=button', $result); } public function testFromContextProductReturnsProductUrl() @@ -46,7 +56,7 @@ public function testFromContextProductReturnsProductUrl() $result = $this->testee->from_context('product', $request_data); - $this->assertEquals('https://example.com/product/123', $result); + $this->assertEquals('https://example.com/product/123?pcp-return=button', $result); } public function testFromContextProductThrowsExceptionWhenNoUrl() @@ -80,7 +90,7 @@ public function testFromContextPayNowReturnsOrderPaymentUrl() $result = $this->testee->from_context('pay-now', $request_data); - $this->assertEquals('https://example.com/checkout/pay/123?key=abc', $result); + $this->assertEquals('https://example.com/checkout/pay/123?key=abc&pcp-return=button', $result); } public function testFromContextPayNowThrowsExceptionWhenOrderNotFound() @@ -100,7 +110,7 @@ public function testFromContextCheckoutReturnsCheckoutUrl() $result = $this->testee->from_context('checkout'); - $this->assertEquals('https://example.com/checkout', $result); + $this->assertEquals('https://example.com/checkout?pcp-return=button', $result); } public function testFromContextDefaultReturnsCheckoutUrl() @@ -109,9 +119,18 @@ public function testFromContextDefaultReturnsCheckoutUrl() $result = $this->testee->from_context('unknown-context'); - $this->assertEquals('https://example.com/checkout', $result); + $this->assertEquals('https://example.com/checkout?pcp-return=button', $result); } + public function testFromContextReturnsCartUrlWithCustomArgs() + { + when('wc_get_cart_url')->justReturn('https://example.com/cart'); + + $result = $this->testee->from_context('cart', [], ['session' => '123']); + + $this->assertEquals('https://example.com/cart?pcp-return=button&session=123', $result); + } + public function cartContextProvider(): array { return [ diff --git a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php index ce07a84cd4..f31da0b9ba 100644 --- a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php +++ b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php @@ -15,6 +15,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\ReturnUrlFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ContactPreferenceFactory; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; +use WooCommerce\PayPalCommerce\Button\Session\CartDataFactory; +use WooCommerce\PayPalCommerce\Button\Session\CartDataTransientStorage; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\TestCase; use WooCommerce\PayPalCommerce\WcGateway\CardBillingMode; @@ -173,6 +175,8 @@ protected function mockTestee() $session_handler, $settings, $early_order_handler, + Mockery::mock(CartDataFactory::class), + Mockery::mock(CartDataTransientStorage::class), false, CardBillingMode::MINIMAL_INPUT, false, diff --git a/tests/PHPUnit/TestCase.php b/tests/PHPUnit/TestCase.php index 9e743085ae..1cccfbc089 100644 --- a/tests/PHPUnit/TestCase.php +++ b/tests/PHPUnit/TestCase.php @@ -41,6 +41,11 @@ public function setUp(): void when('delete_transient')->returnArg(); when('wcs_get_subscription')->returnArg(); + + if (!defined('HOUR_IN_SECONDS')) { + define('HOUR_IN_SECONDS', 60 * 60); + } + setUp(); }