diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index b1f19bbac..0e3fae7b0 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -100,7 +100,8 @@ const bootstrap = () => { if (PayPalCommerceGateway.mini_cart_buttons_enabled === '1') { const miniCartBootstrap = new MiniCartBootstap( PayPalCommerceGateway, - renderer + renderer, + errorHandler, ); miniCartBootstrap.init(); @@ -112,6 +113,7 @@ const bootstrap = () => { PayPalCommerceGateway, renderer, messageRenderer, + errorHandler, ); singleProductBootstrap.init(); @@ -121,6 +123,7 @@ const bootstrap = () => { const cartBootstrap = new CartBootstrap( PayPalCommerceGateway, renderer, + errorHandler, ); cartBootstrap.init(); @@ -131,7 +134,8 @@ const bootstrap = () => { PayPalCommerceGateway, renderer, messageRenderer, - spinner + spinner, + errorHandler, ); checkoutBootstap.init(); @@ -142,7 +146,8 @@ const bootstrap = () => { PayPalCommerceGateway, renderer, messageRenderer, - spinner + spinner, + errorHandler, ); payNowBootstrap.init(); } diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js index 98e14e134..aa456a064 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js @@ -59,14 +59,16 @@ class CheckoutActionHandler { ); } else { errorHandler.clear(); - if (data.data.details.length > 0) { + if (data.data.errors.length > 0) { + errorHandler.messages(data.data.errors); + } else if (data.data.details.length > 0) { errorHandler.message(data.data.details.map(d => `${d.issue} ${d.description}`).join('
'), true); } else { errorHandler.message(data.data.message, true); } } - throw new Error(data.data.message); + throw {type: 'create-order-error', data: data.data}; } const input = document.createElement('input'); input.setAttribute('type', 'hidden'); @@ -82,9 +84,15 @@ class CheckoutActionHandler { onCancel: () => { spinner.unblock(); }, - onError: () => { - this.errorHandler.genericError(); + onError: (err) => { + console.error(err); spinner.unblock(); + + if (err && err.type === 'create-order-error') { + return; + } + + this.errorHandler.genericError(); } } } diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js index d32ca440f..ebc00bfdc 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js @@ -1,10 +1,10 @@ import CartActionHandler from '../ActionHandler/CartActionHandler'; -import ErrorHandler from '../ErrorHandler'; class CartBootstrap { - constructor(gateway, renderer) { + constructor(gateway, renderer, errorHandler) { this.gateway = gateway; this.renderer = renderer; + this.errorHandler = errorHandler; } init() { @@ -28,7 +28,7 @@ class CartBootstrap { render() { const actionHandler = new CartActionHandler( PayPalCommerceGateway, - new ErrorHandler(this.gateway.labels.error.generic), + this.errorHandler, ); this.renderer.render( diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js index b491bcb1e..af04b6811 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js @@ -1,4 +1,3 @@ -import ErrorHandler from '../ErrorHandler'; import CheckoutActionHandler from '../ActionHandler/CheckoutActionHandler'; import {setVisible, setVisibleByClass} from '../Helper/Hiding'; import { @@ -8,11 +7,12 @@ import { } from "../Helper/CheckoutMethodState"; class CheckoutBootstap { - constructor(gateway, renderer, messages, spinner) { + constructor(gateway, renderer, messages, spinner, errorHandler) { this.gateway = gateway; this.renderer = renderer; this.messages = messages; this.spinner = spinner; + this.errorHandler = errorHandler; this.standardOrderButtonSelector = ORDER_BUTTON_SELECTOR; } @@ -60,7 +60,7 @@ class CheckoutBootstap { } const actionHandler = new CheckoutActionHandler( PayPalCommerceGateway, - new ErrorHandler(this.gateway.labels.error.generic), + this.errorHandler, this.spinner ); diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js index 35465c12e..443c9afe4 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js @@ -1,10 +1,10 @@ -import ErrorHandler from '../ErrorHandler'; import CartActionHandler from '../ActionHandler/CartActionHandler'; class MiniCartBootstap { - constructor(gateway, renderer) { + constructor(gateway, renderer, errorHandler) { this.gateway = gateway; this.renderer = renderer; + this.errorHandler = errorHandler; this.actionHandler = null; } @@ -12,7 +12,7 @@ class MiniCartBootstap { this.actionHandler = new CartActionHandler( PayPalCommerceGateway, - new ErrorHandler(this.gateway.labels.error.generic), + this.errorHandler, ); this.render(); diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js index 4b2583c98..4d69532eb 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js @@ -2,8 +2,8 @@ import CheckoutBootstap from './CheckoutBootstap' import {isChangePaymentPage} from "../Helper/Subscriptions"; class PayNowBootstrap extends CheckoutBootstap { - constructor(gateway, renderer, messages, spinner) { - super(gateway, renderer, messages, spinner) + constructor(gateway, renderer, messages, spinner, errorHandler) { + super(gateway, renderer, messages, spinner, errorHandler) } updateUi() { diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js index ecb44eabe..7bb515df4 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js @@ -1,12 +1,12 @@ -import ErrorHandler from '../ErrorHandler'; import UpdateCart from "../Helper/UpdateCart"; import SingleProductActionHandler from "../ActionHandler/SingleProductActionHandler"; class SingleProductBootstap { - constructor(gateway, renderer, messages) { + constructor(gateway, renderer, messages, errorHandler) { this.gateway = gateway; this.renderer = renderer; this.messages = messages; + this.errorHandler = errorHandler; } @@ -81,7 +81,7 @@ class SingleProductBootstap { this.messages.hideMessages(); }, document.querySelector('form.cart'), - new ErrorHandler(this.gateway.labels.error.generic), + this.errorHandler, ); this.renderer.render( diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index cd30058cf..aa01ff1bc 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -27,7 +27,7 @@ use WooCommerce\PayPalCommerce\Onboarding\Environment; use WooCommerce\PayPalCommerce\Onboarding\State; return array( - 'button.client_id' => static function ( ContainerInterface $container ): string { + 'button.client_id' => static function ( ContainerInterface $container ): string { $settings = $container->get( 'wcgateway.settings' ); $client_id = $settings->has( 'client_id' ) ? $settings->get( 'client_id' ) : ''; @@ -45,7 +45,7 @@ return array( return $env->current_environment_is( Environment::SANDBOX ) ? CONNECT_WOO_SANDBOX_CLIENT_ID : CONNECT_WOO_CLIENT_ID; }, - 'button.smart-button' => static function ( ContainerInterface $container ): SmartButtonInterface { + 'button.smart-button' => static function ( ContainerInterface $container ): SmartButtonInterface { $state = $container->get( 'onboarding.state' ); /** @@ -92,16 +92,16 @@ return array( $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'button.url' => static function ( ContainerInterface $container ): string { + 'button.url' => static function ( ContainerInterface $container ): string { return plugins_url( '/modules/ppcp-button/', dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php' ); }, - 'button.request-data' => static function ( ContainerInterface $container ): RequestData { + 'button.request-data' => static function ( ContainerInterface $container ): RequestData { return new RequestData(); }, - 'button.endpoint.change-cart' => static function ( ContainerInterface $container ): ChangeCartEndpoint { + 'button.endpoint.change-cart' => static function ( ContainerInterface $container ): ChangeCartEndpoint { if ( ! \WC()->cart ) { throw new RuntimeException( 'cant initialize endpoint at this moment' ); } @@ -113,7 +113,7 @@ return array( $logger = $container->get( 'woocommerce.logger.woocommerce' ); return new ChangeCartEndpoint( $cart, $shipping, $request_data, $purchase_unit_factory, $data_store, $logger ); }, - 'button.endpoint.create-order' => static function ( ContainerInterface $container ): CreateOrderEndpoint { + 'button.endpoint.create-order' => static function ( ContainerInterface $container ): CreateOrderEndpoint { $request_data = $container->get( 'button.request-data' ); $purchase_unit_factory = $container->get( 'api.factory.purchase-unit' ); $order_endpoint = $container->get( 'api.endpoint.order' ); @@ -134,10 +134,11 @@ return array( $early_order_handler, $registration_needed, $container->get( 'wcgateway.settings.card_billing_data_mode' ), + $container->get( 'button.early-wc-checkout-validation-enabled' ), $logger ); }, - 'button.helper.early-order-handler' => static function ( ContainerInterface $container ) : EarlyOrderHandler { + 'button.helper.early-order-handler' => static function ( ContainerInterface $container ) : EarlyOrderHandler { $state = $container->get( 'onboarding.state' ); $order_processor = $container->get( 'wcgateway.order-processor' ); @@ -145,7 +146,7 @@ return array( $prefix = $container->get( 'api.prefix' ); return new EarlyOrderHandler( $state, $order_processor, $session_handler, $prefix ); }, - 'button.endpoint.approve-order' => static function ( ContainerInterface $container ): ApproveOrderEndpoint { + 'button.endpoint.approve-order' => static function ( ContainerInterface $container ): ApproveOrderEndpoint { $request_data = $container->get( 'button.request-data' ); $order_endpoint = $container->get( 'api.endpoint.order' ); $session_handler = $container->get( 'session.handler' ); @@ -165,7 +166,7 @@ return array( $logger ); }, - 'button.endpoint.data-client-id' => static function( ContainerInterface $container ) : DataClientIdEndpoint { + 'button.endpoint.data-client-id' => static function( ContainerInterface $container ) : DataClientIdEndpoint { $request_data = $container->get( 'button.request-data' ); $identity_token = $container->get( 'api.endpoint.identity-token' ); $logger = $container->get( 'woocommerce.logger.woocommerce' ); @@ -175,39 +176,47 @@ return array( $logger ); }, - 'button.endpoint.vault-paypal' => static function( ContainerInterface $container ) : StartPayPalVaultingEndpoint { + 'button.endpoint.vault-paypal' => static function( ContainerInterface $container ) : StartPayPalVaultingEndpoint { return new StartPayPalVaultingEndpoint( $container->get( 'button.request-data' ), $container->get( 'api.endpoint.payment-token' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, - 'button.helper.three-d-secure' => static function ( ContainerInterface $container ): ThreeDSecure { + 'button.helper.three-d-secure' => static function ( ContainerInterface $container ): ThreeDSecure { $logger = $container->get( 'woocommerce.logger.woocommerce' ); return new ThreeDSecure( $logger ); }, - 'button.helper.messages-apply' => static function ( ContainerInterface $container ): MessagesApply { + 'button.helper.messages-apply' => static function ( ContainerInterface $container ): MessagesApply { return new MessagesApply( $container->get( 'api.shop.country' ) ); }, - 'button.is-logged-in' => static function ( ContainerInterface $container ): bool { + 'button.is-logged-in' => static function ( ContainerInterface $container ): bool { return is_user_logged_in(); }, - 'button.registration-required' => static function ( ContainerInterface $container ): bool { + 'button.registration-required' => static function ( ContainerInterface $container ): bool { return WC()->checkout()->is_registration_required(); }, - 'button.current-user-must-register' => static function ( ContainerInterface $container ): bool { + 'button.current-user-must-register' => static function ( ContainerInterface $container ): bool { return ! $container->get( 'button.is-logged-in' ) && $container->get( 'button.registration-required' ); }, - 'button.basic-checkout-validation-enabled' => static function ( ContainerInterface $container ): bool { + 'button.basic-checkout-validation-enabled' => static function ( ContainerInterface $container ): bool { /** * The filter allowing to disable the basic client-side validation of the checkout form * when the PayPal button is clicked. */ - return (bool) apply_filters( 'woocommerce_paypal_payments_basic_checkout_validation_enabled', true ); + return (bool) apply_filters( 'woocommerce_paypal_payments_basic_checkout_validation_enabled', false ); + }, + 'button.early-wc-checkout-validation-enabled' => static function ( ContainerInterface $container ): bool { + /** + * The filter allowing to disable the WC validation of the checkout form + * when the PayPal button is clicked. + * The validation is triggered in a non-standard way and may cause issues on some sites. + */ + return (bool) apply_filters( 'woocommerce_paypal_payments_early_wc_checkout_validation_enabled', true ); }, ); diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php index 3e6e5cb9d..b22b851b9 100644 --- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php @@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\Button\Endpoint; use Exception; use Psr\Log\LoggerInterface; use stdClass; +use Throwable; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount; use WooCommerce\PayPalCommerce\ApiClient\Entity\ApplicationContext; @@ -25,6 +26,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory; +use WooCommerce\PayPalCommerce\Button\Exception\ValidationException; +use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler; use WooCommerce\PayPalCommerce\Session\SessionHandler; use WooCommerce\PayPalCommerce\Subscription\FreeTrialHandlerTrait; @@ -128,6 +131,13 @@ class CreateOrderEndpoint implements EndpointInterface { */ protected $card_billing_data_mode; + /** + * Whether to execute WC validation of the checkout form. + * + * @var bool + */ + protected $early_validation_enabled; + /** * The logger. * @@ -148,6 +158,7 @@ class CreateOrderEndpoint implements EndpointInterface { * @param EarlyOrderHandler $early_order_handler The EarlyOrderHandler object. * @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. * @param LoggerInterface $logger The logger. */ public function __construct( @@ -161,6 +172,7 @@ class CreateOrderEndpoint implements EndpointInterface { EarlyOrderHandler $early_order_handler, bool $registration_needed, string $card_billing_data_mode, + bool $early_validation_enabled, LoggerInterface $logger ) { @@ -174,6 +186,7 @@ class CreateOrderEndpoint implements EndpointInterface { $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->logger = $logger; } @@ -233,8 +246,14 @@ class CreateOrderEndpoint implements EndpointInterface { $this->set_bn_code( $data ); - if ( 'pay-now' === $data['context'] && get_option( 'woocommerce_terms_page_id', '' ) !== '' ) { - $this->validate_paynow_form( $data['form'] ); + $form_fields = $data['form'] ?? null; + + if ( $this->early_validation_enabled && is_array( $form_fields ) ) { + $this->validate_form( $form_fields ); + } + + if ( 'pay-now' === $data['context'] && is_array( $form_fields ) && get_option( 'woocommerce_terms_page_id', '' ) !== '' ) { + $this->validate_paynow_form( $form_fields ); } try { @@ -264,6 +283,13 @@ class CreateOrderEndpoint implements EndpointInterface { wp_send_json_success( $order->to_array() ); return true; + } catch ( ValidationException $error ) { + wp_send_json_error( + array( + 'message' => $error->getMessage(), + 'errors' => $error->errors(), + ) + ); } catch ( \RuntimeException $error ) { $this->logger->error( 'Order creation failed: ' . $error->getMessage() ); @@ -481,16 +507,33 @@ class CreateOrderEndpoint implements EndpointInterface { return $payment_method; } + /** + * Checks whether the form fields are valid. + * + * @param array $form_fields The form fields. + * @throws ValidationException When fields are not valid. + */ + private function validate_form( array $form_fields ): void { + try { + $v = new CheckoutFormValidator(); + $v->validate( $form_fields ); + } catch ( ValidationException $exception ) { + throw $exception; + } catch ( Throwable $exception ) { + $this->logger->error( "Form validation execution failed. {$exception->getMessage()} {$exception->getFile()}:{$exception->getLine()}" ); + } + } + /** * Checks whether the terms input field is checked. * * @param array $form_fields The form fields. - * @throws \RuntimeException When field is not checked. + * @throws ValidationException When field is not checked. */ - private function validate_paynow_form( array $form_fields ) { + private function validate_paynow_form( array $form_fields ): void { if ( isset( $form_fields['terms-field'] ) && ! isset( $form_fields['terms'] ) ) { - throw new \RuntimeException( - __( 'Please read and accept the terms and conditions to proceed with your order.', 'woocommerce-paypal-payments' ) + throw new ValidationException( + array( __( 'Please read and accept the terms and conditions to proceed with your order.', 'woocommerce-paypal-payments' ) ) ); } } diff --git a/modules/ppcp-button/src/Exception/ValidationException.php b/modules/ppcp-button/src/Exception/ValidationException.php new file mode 100644 index 000000000..f8aedf291 --- /dev/null +++ b/modules/ppcp-button/src/Exception/ValidationException.php @@ -0,0 +1,47 @@ +errors = $errors; + + if ( ! $message ) { + $message = implode( ' ', $errors ); + } + + parent::__construct( $message ); + } + + /** + * The error messages. + * + * @return string[] + */ + public function errors(): array { + return $this->errors; + } +} diff --git a/modules/ppcp-button/src/Validation/CheckoutFormValidator.php b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php new file mode 100644 index 000000000..729125638 --- /dev/null +++ b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php @@ -0,0 +1,43 @@ +validate_checkout( $data, $errors ); + + if ( $errors->has_errors() ) { + throw new ValidationException( $errors->get_error_messages() ); + } + } +} diff --git a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php index d185f37ea..4e6d82a6c 100644 --- a/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php +++ b/tests/PHPUnit/Button/Endpoint/CreateOrderEndpointTest.php @@ -166,6 +166,7 @@ class CreateOrderEndpointTest extends TestCase $early_order_handler, false, CardBillingMode::MINIMAL_INPUT, + false, new NullLogger() ); return array($payer_factory, $testee);