From 46ea7621d3bd208b3553d1990763d36c15a978cc Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 14 Jun 2023 12:44:06 +0300 Subject: [PATCH] Create wc order in approval webhook if missing --- modules/ppcp-button/resources/js/button.js | 13 +- modules/ppcp-button/services.php | 4 +- .../ppcp-button/src/Assets/SmartButton.php | 11 ++ .../src/Helper/CheckoutFormSaver.php | 21 ++ modules/ppcp-session/src/MemoryWcSession.php | 75 ++++++++ modules/ppcp-session/src/SessionHandler.php | 34 ++++ modules/ppcp-webhooks/services.php | 8 +- .../src/Handler/CheckoutOrderApproved.php | 180 ++++++++++++++---- 8 files changed, 302 insertions(+), 44 deletions(-) create mode 100644 modules/ppcp-session/src/MemoryWcSession.php diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index 6708b0275..7ac5bb38e 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -28,6 +28,8 @@ const cardsSpinner = new Spinner('#ppcp-hosted-fields'); const bootstrap = () => { const checkoutFormSelector = 'form.woocommerce-checkout'; + const context = PayPalCommerceGateway.context; + const errorHandler = new ErrorHandler( PayPalCommerceGateway.labels.error.generic, document.querySelector(checkoutFormSelector) ?? document.querySelector('.woocommerce-notices-wrapper') @@ -58,7 +60,7 @@ const bootstrap = () => { } }); - const onSmartButtonClick = (data, actions) => { + const onSmartButtonClick = async (data, actions) => { window.ppcpFundingSource = data.fundingSource; const requiredFields = jQuery('form.woocommerce-checkout .validate-required:visible :input'); requiredFields.each((i, input) => { @@ -120,13 +122,20 @@ const bootstrap = () => { freeTrialHandler.handle(); return actions.reject(); } + + if (context === 'checkout' && !PayPalCommerceGateway.funding_sources_without_redirect.includes(data.fundingSource)) { + try { + await formSaver.save(form); + } catch (error) { + console.error(error); + } + } }; const onSmartButtonsInit = () => { buttonsSpinner.unblock(); }; const renderer = new Renderer(creditCardRenderer, PayPalCommerceGateway, onSmartButtonClick, onSmartButtonsInit); const messageRenderer = new MessageRenderer(PayPalCommerceGateway.messages); - const context = PayPalCommerceGateway.context; if (context === 'mini-cart' || context === 'product') { if (PayPalCommerceGateway.mini_cart_buttons_enabled === '1') { const miniCartBootstrap = new MiniCartBootstap( diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index fffd57226..e100e5ea3 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -216,7 +216,9 @@ return array( ); }, 'button.checkout-form-saver' => static function ( ContainerInterface $container ): CheckoutFormSaver { - return new CheckoutFormSaver(); + return new CheckoutFormSaver( + $container->get( 'session.handler' ) + ); }, 'button.endpoint.save-checkout-form' => static function ( ContainerInterface $container ): SaveCheckoutFormEndpoint { return new SaveCheckoutFormEndpoint( diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 1c9862a5e..eb7a020dd 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -173,6 +173,13 @@ class SmartButton implements SmartButtonInterface { */ private $pay_now_contexts; + /** + * The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. + * + * @var string[] + */ + private $funding_sources_without_redirect; + /** * The logger. * @@ -208,6 +215,7 @@ class SmartButton implements SmartButtonInterface { * @param bool $basic_checkout_validation_enabled Whether the basic JS validation of the form iss enabled. * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form. * @param array $pay_now_contexts The contexts that should have the Pay Now button. + * @param string[] $funding_sources_without_redirect The sources that do not cause issues about redirecting (on mobile, ...) and sometimes not returning back. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -229,6 +237,7 @@ class SmartButton implements SmartButtonInterface { bool $basic_checkout_validation_enabled, bool $early_validation_enabled, array $pay_now_contexts, + array $funding_sources_without_redirect, LoggerInterface $logger ) { @@ -250,6 +259,7 @@ class SmartButton implements SmartButtonInterface { $this->basic_checkout_validation_enabled = $basic_checkout_validation_enabled; $this->early_validation_enabled = $early_validation_enabled; $this->pay_now_contexts = $pay_now_contexts; + $this->funding_sources_without_redirect = $funding_sources_without_redirect; $this->logger = $logger; } @@ -939,6 +949,7 @@ class SmartButton implements SmartButtonInterface { 'mini_cart_buttons_enabled' => $this->settings_status->is_smart_button_enabled_for_location( 'mini-cart' ), 'basic_checkout_validation_enabled' => $this->basic_checkout_validation_enabled, 'early_checkout_validation_enabled' => $this->early_validation_enabled, + 'funding_sources_without_redirect' => $this->funding_sources_without_redirect, ); if ( $this->style_for_context( 'layout', 'mini-cart' ) !== 'horizontal' ) { diff --git a/modules/ppcp-button/src/Helper/CheckoutFormSaver.php b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php index 73f8eeefc..c2f10ca28 100644 --- a/modules/ppcp-button/src/Helper/CheckoutFormSaver.php +++ b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php @@ -10,11 +10,30 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button\Helper; use WC_Checkout; +use WooCommerce\PayPalCommerce\Session\SessionHandler; /** * Class CheckoutFormSaver */ class CheckoutFormSaver extends WC_Checkout { + /** + * The Session handler. + * + * @var SessionHandler + */ + private $session_handler; + + /** + * CheckoutFormSaver constructor. + * + * @param SessionHandler $session_handler The session handler. + */ + public function __construct( + SessionHandler $session_handler + ) { + $this->session_handler = $session_handler; + } + /** * Saves the form data to the WC customer and session. * @@ -28,5 +47,7 @@ class CheckoutFormSaver extends WC_Checkout { $data = $this->get_posted_data(); $this->update_session( $data ); + + $this->session_handler->replace_checkout_form( $data ); } } diff --git a/modules/ppcp-session/src/MemoryWcSession.php b/modules/ppcp-session/src/MemoryWcSession.php new file mode 100644 index 000000000..583aa5a9d --- /dev/null +++ b/modules/ppcp-session/src/MemoryWcSession.php @@ -0,0 +1,75 @@ +session->get_session). + * + * @var array + */ + private static $data; + + /** + * The customer ID. + * + * @var string|int + */ + private static $customer_id; + + /** + * Enqueues this session handler with the given data to be used by WC. + * + * @param array $session_data The session data (from WC()->session->get_session). + * @param int|string $customer_id The customer ID. + */ + public static function replace_session_handler( array $session_data, $customer_id ): void { + self::$data = $session_data; + self::$customer_id = $customer_id; + + add_filter( + 'woocommerce_session_handler', + function () { + return MemoryWcSession::class; + } + ); + } + + /** + * @inerhitDoc + */ + public function init_session_cookie() { + $this->_customer_id = self::$customer_id; + $this->_data = self::$data; + } + + /** + * @inerhitDoc + */ + public function get_session_data() { + return self::$data; + } + + /** + * @inerhitDoc + */ + public function forget_session() { + self::$data = array(); + + parent::forget_session(); + } +} diff --git a/modules/ppcp-session/src/SessionHandler.php b/modules/ppcp-session/src/SessionHandler.php index be269332e..d85c40b65 100644 --- a/modules/ppcp-session/src/SessionHandler.php +++ b/modules/ppcp-session/src/SessionHandler.php @@ -47,6 +47,13 @@ class SessionHandler { */ private $funding_source = null; + /** + * The checkout form data. + * + * @var array + */ + private $checkout_form = array(); + /** * Returns the order. * @@ -73,6 +80,30 @@ class SessionHandler { $this->store_session(); } + /** + * Returns the checkout form data. + * + * @return array + */ + public function checkout_form(): array { + $this->load_session(); + + return $this->checkout_form; + } + + /** + * Replaces the checkout form data. + * + * @param array $checkout_form The checkout form data. + */ + public function replace_checkout_form( array $checkout_form ): void { + $this->load_session(); + + $this->checkout_form = $checkout_form; + + $this->store_session(); + } + /** * Returns the BN Code. * @@ -153,6 +184,7 @@ class SessionHandler { $this->bn_code = ''; $this->insufficient_funding_tries = 0; $this->funding_source = null; + $this->checkout_form = array(); $this->store_session(); return $this; } @@ -190,6 +222,7 @@ class SessionHandler { if ( ! is_string( $this->funding_source ) ) { $this->funding_source = null; } + $this->checkout_form = $data['checkout_form'] ?? array(); } /** @@ -204,6 +237,7 @@ class SessionHandler { 'bn_code' => $obj->bn_code, 'insufficient_funding_tries' => $obj->insufficient_funding_tries, 'funding_source' => $obj->funding_source, + 'checkout_form' => $obj->checkout_form, ); } } diff --git a/modules/ppcp-webhooks/services.php b/modules/ppcp-webhooks/services.php index 24bc9e971..cd0424a19 100644 --- a/modules/ppcp-webhooks/services.php +++ b/modules/ppcp-webhooks/services.php @@ -82,7 +82,13 @@ return array( $payment_token_factory = $container->get( 'vaulting.payment-token-factory' ); return array( - new CheckoutOrderApproved( $logger, $order_endpoint ), + new CheckoutOrderApproved( + $logger, + $order_endpoint, + $container->get( 'session.handler' ), + $container->get( 'wcgateway.funding-source.renderer' ), + $container->get( 'wcgateway.order-processor' ) + ), new CheckoutOrderCompleted( $logger ), new CheckoutPaymentApprovalReversed( $logger ), new PaymentCaptureRefunded( $logger ), diff --git a/modules/ppcp-webhooks/src/Handler/CheckoutOrderApproved.php b/modules/ppcp-webhooks/src/Handler/CheckoutOrderApproved.php index a37cab34c..f7a8d8f27 100644 --- a/modules/ppcp-webhooks/src/Handler/CheckoutOrderApproved.php +++ b/modules/ppcp-webhooks/src/Handler/CheckoutOrderApproved.php @@ -9,11 +9,18 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Webhooks\Handler; +use WC_Checkout; +use WC_Order; +use WC_Session_Handler; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; -use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; +use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus; use Psr\Log\LoggerInterface; +use WooCommerce\PayPalCommerce\Session\MemoryWcSession; +use WooCommerce\PayPalCommerce\Session\SessionHandler; +use WooCommerce\PayPalCommerce\WcGateway\FundingSource\FundingSourceRenderer; use WooCommerce\PayPalCommerce\WcGateway\Gateway\OXXO\OXXOGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayUponInvoice\PayUponInvoiceGateway; +use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor; /** * Class CheckoutOrderApproved @@ -36,15 +43,48 @@ class CheckoutOrderApproved implements RequestHandler { */ private $order_endpoint; + /** + * The Session handler. + * + * @var SessionHandler + */ + private $session_handler; + + /** + * The funding source renderer. + * + * @var FundingSourceRenderer + */ + protected $funding_source_renderer; + + /** + * The processor for orders. + * + * @var OrderProcessor + */ + protected $order_processor; + /** * CheckoutOrderApproved constructor. * - * @param LoggerInterface $logger The logger. - * @param OrderEndpoint $order_endpoint The order endpoint. + * @param LoggerInterface $logger The logger. + * @param OrderEndpoint $order_endpoint The order endpoint. + * @param SessionHandler $session_handler The session handler. + * @param FundingSourceRenderer $funding_source_renderer The funding source renderer. + * @param OrderProcessor $order_processor The Order Processor. */ - public function __construct( LoggerInterface $logger, OrderEndpoint $order_endpoint ) { - $this->logger = $logger; - $this->order_endpoint = $order_endpoint; + public function __construct( + LoggerInterface $logger, + OrderEndpoint $order_endpoint, + SessionHandler $session_handler, + FundingSourceRenderer $funding_source_renderer, + OrderProcessor $order_processor + ) { + $this->logger = $logger; + $this->order_endpoint = $order_endpoint; + $this->session_handler = $session_handler; + $this->funding_source_renderer = $funding_source_renderer; + $this->order_processor = $order_processor; } /** @@ -77,36 +117,93 @@ class CheckoutOrderApproved implements RequestHandler { * @return \WP_REST_Response */ public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { - $custom_ids = $this->get_custom_ids_from_request( $request ); - if ( empty( $custom_ids ) ) { - return $this->no_custom_ids_response( $request ); - } - - try { - $order = isset( $request['resource']['id'] ) ? - $this->order_endpoint->order( $request['resource']['id'] ) : null; - if ( ! $order ) { - $message = sprintf( - 'No paypal payment for webhook event %s was found.', - isset( $request['id'] ) ? $request['id'] : '' - ); - return $this->failure_response( $message ); - } - - if ( $order->intent() === 'CAPTURE' ) { - $order = $this->order_endpoint->capture( $order ); - } - } catch ( RuntimeException $error ) { - $message = sprintf( - 'Could not capture payment for webhook event %s.', - isset( $request['id'] ) ? $request['id'] : '' + $order_id = isset( $request['resource']['id'] ) ? $request['resource']['id'] : null; + if ( ! $order_id ) { + return $this->failure_response( + sprintf( + 'No order ID in webhook event %s.', + $request['id'] ?: '' + ) ); - return $this->failure_response( $message ); } - $wc_orders = $this->get_wc_orders_from_custom_ids( $custom_ids ); - if ( ! $wc_orders ) { - return $this->no_wc_orders_response( $request ); + $order = $this->order_endpoint->order( $order_id ); + + if ( $order->status()->is( OrderStatus::COMPLETED ) ) { + return $this->success_response(); + } + + $wc_orders = array(); + + $custom_ids = $this->get_wc_order_ids_from_request( $request ); + if ( empty( $custom_ids ) ) { + $custom_ids = $this->get_wc_customer_ids_from_request( $request ); + if ( empty( $custom_ids ) ) { + return $this->no_custom_ids_response( $request ); + } + + $customer_id = $custom_ids[0]; + + $wc_session = new WC_Session_Handler(); + + $session_data = $wc_session->get_session( $customer_id ); + if ( ! is_array( $session_data ) ) { + return $this->failure_response( "Failed to get session data {$customer_id}" ); + } + + MemoryWcSession::replace_session_handler( $session_data, $customer_id ); + + wc_load_cart(); + WC()->cart->get_cart_from_session(); + WC()->cart->calculate_shipping(); + + $form = $this->session_handler->checkout_form(); + + $checkout = new WC_Checkout(); + $wc_order_id = $checkout->create_order( $form ); + $wc_order = wc_get_order( $wc_order_id ); + if ( ! $wc_order instanceof WC_Order ) { + return $this->failure_response( + sprintf( + 'Failed to create WC order in webhook event %s.', + $request['id'] ?: '' + ) + ); + } + + $funding_source = $this->session_handler->funding_source(); + if ( $funding_source ) { + $wc_order->set_payment_method_title( $this->funding_source_renderer->render_name( $funding_source ) ); + } + + if ( is_numeric( $customer_id ) ) { + $wc_order->set_customer_id( (int) $customer_id ); + } + + $wc_order->save(); + + $wc_orders[] = $wc_order; + + add_action( + 'shutdown', + function () use ( $customer_id ): void { + $session = WC()->session; + assert( $session instanceof WC_Session_Handler ); + + /** + * Wrong type-hint. + * + * @psalm-suppress InvalidScalarArgument + */ + $session->delete_session( $customer_id ); + $session->forget_session(); + } + ); + } else { + $wc_orders = $this->get_wc_orders_from_custom_ids( $custom_ids ); + if ( ! $wc_orders ) { + return $this->no_wc_orders_response( $request ); + } } foreach ( $wc_orders as $wc_order ) { @@ -117,17 +214,20 @@ class CheckoutOrderApproved implements RequestHandler { if ( ! in_array( $wc_order->get_status(), array( 'pending', 'on-hold' ), true ) ) { continue; } - if ( $order->intent() === 'CAPTURE' ) { - $wc_order->payment_complete(); - } else { - $wc_order->update_status( - 'on-hold', - __( 'Payment can be captured.', 'woocommerce-paypal-payments' ) + + if ( ! $this->order_processor->process( $wc_order ) ) { + return $this->failure_response( + sprintf( + 'Failed to process WC order %s: %s.', + (string) $wc_order->get_id(), + $this->order_processor->last_error() + ) ); } + $this->logger->info( sprintf( - 'Order %s has been updated through PayPal', + 'WC order %s has been processed after approval in PayPal.', (string) $wc_order->get_id() ) );