diff --git a/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php b/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php index fcb656dce..249f5a106 100644 --- a/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/OrderEndpoint.php @@ -518,6 +518,11 @@ class OrderEndpoint { } } + /** + * The filter can be used to modify the order patching request body data (the final prices, items). + */ + $patches_array = apply_filters( 'ppcp_patch_order_request_body_data', $patches_array ); + $bearer = $this->bearer->bearer(); $url = trailingslashit( $this->host ) . 'v2/checkout/orders/' . $order_to_update->id(); $args = array( diff --git a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php index b2e642fb4..ad138cc75 100644 --- a/modules/ppcp-api-client/src/Entity/PurchaseUnit.php +++ b/modules/ppcp-api-client/src/Entity/PurchaseUnit.php @@ -284,7 +284,14 @@ class PurchaseUnit { $this->items() ), ); - if ( $ditch_items_when_mismatch && $this->ditch_items_when_mismatch( $this->amount(), ...$this->items() ) ) { + + $ditch = $ditch_items_when_mismatch && $this->ditch_items_when_mismatch( $this->amount(), ...$this->items() ); + /** + * The filter can be used to control when the items and totals breakdown are removed from PayPal order info. + */ + $ditch = apply_filters( 'ppcp_ditch_items_breakdown', $ditch, $this ); + + if ( $ditch ) { unset( $purchase_unit['items'] ); unset( $purchase_unit['amount']['breakdown'] ); } diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index a118211a2..b77395657 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -17,6 +17,7 @@ import { import {hide, setVisible, setVisibleByClass} from "./modules/Helper/Hiding"; import {isChangePaymentPage} from "./modules/Helper/Subscriptions"; import FreeTrialHandler from "./modules/ActionHandler/FreeTrialHandler"; +import FormSaver from './modules/Helper/FormSaver'; // TODO: could be a good idea to have a separate spinner for each gateway, // but I think we care mainly about the script loading, so one spinner should be enough. @@ -24,11 +25,18 @@ const buttonsSpinner = new Spinner(document.querySelector('.ppc-button-wrapper') const cardsSpinner = new Spinner('#ppcp-hosted-fields'); const bootstrap = () => { + const checkoutFormSelector = 'form.woocommerce-checkout'; + const errorHandler = new ErrorHandler(PayPalCommerceGateway.labels.error.generic); const spinner = new Spinner(); const creditCardRenderer = new CreditCardRenderer(PayPalCommerceGateway, errorHandler, spinner); - const freeTrialHandler = new FreeTrialHandler(PayPalCommerceGateway, spinner, errorHandler); + const formSaver = new FormSaver( + PayPalCommerceGateway.ajax.save_checkout_form.endpoint, + PayPalCommerceGateway.ajax.save_checkout_form.nonce, + ); + + const freeTrialHandler = new FreeTrialHandler(PayPalCommerceGateway, checkoutFormSelector, formSaver, spinner, errorHandler); jQuery('form.woocommerce-checkout input').on('keydown', e => { if (e.key === 'Enter' && [ @@ -88,7 +96,7 @@ const bootstrap = () => { } } - const form = document.querySelector('form.woocommerce-checkout'); + const form = document.querySelector(checkoutFormSelector); if (form) { jQuery('#ppcp-funding-source-form-input').remove(); form.insertAdjacentHTML( diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js index bee968e96..1caf2db80 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/FreeTrialHandler.js @@ -1,44 +1,50 @@ -import {PaymentMethods} from "../Helper/CheckoutMethodState"; -import errorHandler from "../ErrorHandler"; - class FreeTrialHandler { constructor( config, + formSelector, + formSaver, spinner, errorHandler ) { this.config = config; + this.formSelector = formSelector; + this.formSaver = formSaver; this.spinner = spinner; this.errorHandler = errorHandler; } - handle() + async handle() { this.spinner.block(); - fetch(this.config.ajax.vault_paypal.endpoint, { - method: 'POST', - credentials: 'same-origin', - body: JSON.stringify({ - nonce: this.config.ajax.vault_paypal.nonce, - return_url: location.href - }), - }).then(res => { - return res.json(); - }).then(data => { + try { + await this.formSaver.save(document.querySelector(this.formSelector)); + } catch (error) { + console.error(error); + } + + try { + const res = await fetch(this.config.ajax.vault_paypal.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + nonce: this.config.ajax.vault_paypal.nonce, + return_url: location.href, + }), + }); + + const data = await res.json(); + if (!data.success) { - this.spinner.unblock(); - console.error(data); - this.errorHandler.message(data.data.message); throw Error(data.data.message); } location.href = data.data.approve_link; - }).catch(error => { + } catch (error) { this.spinner.unblock(); console.error(error); - this.errorHandler.genericError(); - }); + this.errorHandler.message(data.data.message); + } } } export default FreeTrialHandler; diff --git a/modules/ppcp-button/resources/js/modules/Helper/FormSaver.js b/modules/ppcp-button/resources/js/modules/Helper/FormSaver.js new file mode 100644 index 000000000..332c4a217 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/FormSaver.js @@ -0,0 +1,26 @@ +export default class FormSaver { + constructor(url, nonce) { + this.url = url; + this.nonce = nonce; + } + + async save(form) { + const formData = new FormData(form); + const formJsonObj = Object.fromEntries(formData.entries()); + + const res = await fetch(this.url, { + method: 'POST', + credentials: 'same-origin', + body: JSON.stringify({ + nonce: this.nonce, + form: formJsonObj, + }), + }); + + const data = await res.json(); + + if (!data.success) { + throw Error(data.data.message); + } + } +} diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 0e826fab9..527ce8bf9 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -9,6 +9,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button; +use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; +use WooCommerce\PayPalCommerce\Button\Helper\CheckoutFormSaver; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; use WooCommerce\PayPalCommerce\Button\Assets\DisabledSmartButton; use WooCommerce\PayPalCommerce\Button\Assets\SmartButton; @@ -181,6 +183,16 @@ return array( $logger ); }, + 'button.checkout-form-saver' => static function ( ContainerInterface $container ): CheckoutFormSaver { + return new CheckoutFormSaver(); + }, + 'button.endpoint.save-checkout-form' => static function ( ContainerInterface $container ): SaveCheckoutFormEndpoint { + return new SaveCheckoutFormEndpoint( + $container->get( 'button.request-data' ), + $container->get( 'button.checkout-form-saver' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, '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' ); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 265178906..53af6033e 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -21,6 +21,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; +use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\MessagesApply; use WooCommerce\PayPalCommerce\Onboarding\Environment; @@ -761,22 +762,26 @@ class SmartButton implements SmartButtonInterface { 'redirect' => wc_get_checkout_url(), 'context' => $this->context(), 'ajax' => array( - 'change_cart' => array( + 'change_cart' => array( 'endpoint' => \WC_AJAX::get_endpoint( ChangeCartEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( ChangeCartEndpoint::nonce() ), ), - 'create_order' => array( + 'create_order' => array( 'endpoint' => \WC_AJAX::get_endpoint( CreateOrderEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( CreateOrderEndpoint::nonce() ), ), - 'approve_order' => array( + 'approve_order' => array( 'endpoint' => \WC_AJAX::get_endpoint( ApproveOrderEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( ApproveOrderEndpoint::nonce() ), ), - 'vault_paypal' => array( + 'vault_paypal' => array( 'endpoint' => \WC_AJAX::get_endpoint( StartPayPalVaultingEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( StartPayPalVaultingEndpoint::nonce() ), ), + 'save_checkout_form' => array( + 'endpoint' => \WC_AJAX::get_endpoint( SaveCheckoutFormEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( SaveCheckoutFormEndpoint::nonce() ), + ), ), 'enforce_vault' => $this->has_subscriptions(), 'can_save_vault_token' => $this->can_save_vault_token(), diff --git a/modules/ppcp-button/src/ButtonModule.php b/modules/ppcp-button/src/ButtonModule.php index 004071293..1bb9d4fa6 100644 --- a/modules/ppcp-button/src/ButtonModule.php +++ b/modules/ppcp-button/src/ButtonModule.php @@ -9,6 +9,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Button; +use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface; @@ -156,6 +157,16 @@ class ButtonModule implements ModuleInterface { $endpoint->handle_request(); } ); + + add_action( + 'wc_ajax_' . SaveCheckoutFormEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'button.endpoint.save-checkout-form' ); + assert( $endpoint instanceof SaveCheckoutFormEndpoint ); + + $endpoint->handle_request(); + } + ); } /** diff --git a/modules/ppcp-button/src/Endpoint/SaveCheckoutFormEndpoint.php b/modules/ppcp-button/src/Endpoint/SaveCheckoutFormEndpoint.php new file mode 100644 index 000000000..52f5a32c8 --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/SaveCheckoutFormEndpoint.php @@ -0,0 +1,94 @@ +request_data = $request_data; + $this->checkout_form_saver = $checkout_form_saver; + $this->logger = $logger; + } + + /** + * Returns the nonce. + * + * @return string + */ + public static function nonce(): string { + return self::ENDPOINT; + } + + /** + * Handles the request. + * + * @return bool + */ + public function handle_request(): bool { + try { + $data = $this->request_data->read_request( $this->nonce() ); + + $this->checkout_form_saver->save( $data['form'] ); + + wp_send_json_success(); + return true; + } catch ( Exception $error ) { + $this->logger->error( 'Checkout form saving failed: ' . $error->getMessage() ); + + wp_send_json_error( + array( + 'message' => $error->getMessage(), + ) + ); + return false; + } + } +} diff --git a/modules/ppcp-button/src/Helper/CheckoutFormSaver.php b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php new file mode 100644 index 000000000..73f8eeefc --- /dev/null +++ b/modules/ppcp-button/src/Helper/CheckoutFormSaver.php @@ -0,0 +1,32 @@ + $value ) { + $_POST[ $key ] = $value; + } + $data = $this->get_posted_data(); + + $this->update_session( $data ); + } +} diff --git a/modules/ppcp-button/src/Validation/CheckoutFormValidator.php b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php index b67c96cc3..6a1f9486d 100644 --- a/modules/ppcp-button/src/Validation/CheckoutFormValidator.php +++ b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php @@ -27,16 +27,20 @@ class CheckoutFormValidator extends WC_Checkout { public function validate( array $data ) { $errors = new WP_Error(); - // Some plugins check their fields using $_POST, + // Some plugins check their fields using $_POST or $_REQUEST, // also WC terms checkbox https://github.com/woocommerce/woocommerce/issues/35328 . foreach ( $data as $key => $value ) { - $_POST[ $key ] = $value; + $_POST[ $key ] = $value; + $_REQUEST[ $key ] = $value; } + // Looks like without this WC()->shipping->get_packages() is empty which is used by some plugins. + WC()->cart->calculate_shipping(); + + // Some plugins/filters check is_checkout(). $is_checkout = function () { return true; }; - // Some plugins/filters check is_checkout(). add_filter( 'woocommerce_is_checkout', $is_checkout ); try { // And we must call get_posted_data because it handles the shipping address. @@ -49,8 +53,65 @@ class CheckoutFormValidator extends WC_Checkout { remove_filter( 'woocommerce_is_checkout', $is_checkout ); } - if ( $errors->has_errors() ) { - throw new ValidationException( $errors->get_error_messages() ); + if ( + apply_filters( 'woocommerce_paypal_payments_early_wc_checkout_account_creation_validation_enabled', true ) && + ! is_user_logged_in() && ( $this->is_registration_required() || ! empty( $data['createaccount'] ) ) + ) { + $username = ! empty( $data['account_username'] ) ? $data['account_username'] : ''; + $email = $data['billing_email'] ?? ''; + + if ( email_exists( $email ) ) { + $errors->add( + 'registration-error-email-exists', + apply_filters( + 'woocommerce_registration_error_email_exists', + // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch + __( 'An account is already registered with your email address. Please log in.', 'woocommerce' ), + $email + ) + ); + } + + if ( $username ) { // Empty username is already checked in validate_checkout, and it can be generated. + $username = sanitize_user( $username ); + if ( empty( $username ) || ! validate_username( $username ) ) { + $errors->add( + 'registration-error-invalid-username', + // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch + __( 'Please enter a valid account username.', 'woocommerce' ) + ); + } + + if ( username_exists( $username ) ) { + $errors->add( + 'registration-error-username-exists', + // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch + __( 'An account is already registered with that username. Please choose another.', 'woocommerce' ) + ); + } + } + } + + // Some plugins call wc_add_notice directly. + // We should retrieve such notices, and also clear them to avoid duplicates later. + // TODO: Normally WC converts the messages from validate_checkout into notices, + // maybe we should do the same for consistency, but it requires lots of changes in the way we handle/output errors. + $messages = array_merge( + $errors->get_error_messages(), + array_map( + function ( array $notice ): string { + return $notice['notice']; + }, + wc_get_notices( 'error' ) + ) + ); + + if ( wc_notice_count( 'error' ) > 0 ) { + wc_clear_notices(); + } + + if ( $messages ) { + throw new ValidationException( $messages ); } } } diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index fe0d20d12..02258b410 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -148,6 +148,12 @@ class WCGatewayModule implements ModuleInterface { if ( ! $wc_order instanceof WC_Order ) { return; } + /** + * The filter can be used to remove the rows with PayPal fees in WC orders. + */ + if ( ! apply_filters( 'woocommerce_paypal_payments_show_fees_on_order_admin_page', true, $wc_order ) ) { + return; + } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $fees_renderer->render( $wc_order );