From ed04c3462affd4a0d0ccee53c67ff9fbd30b8e7b Mon Sep 17 00:00:00 2001 From: Daniel Guerra <15204776+danielmx-dev@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:00:30 +0200 Subject: [PATCH] Fix: BNPL payment methods should work when available in the Pay For Order page (#9670) --- changelog/fix-8254-bnpl-pay-for-order | 4 + client/checkout/classic/payment-processing.js | 45 ++++++++- client/checkout/utils/test/upe.test.js | 30 ++++++ client/checkout/utils/upe.js | 12 +++ includes/class-wc-payments-checkout.php | 23 ++++- .../class-wc-payments-customer-service.php | 10 ++ .../class-upe-payment-method.php | 23 ++++- .../test-class-wc-payment-gateway-wcpay.php | 96 +++++++++++++++++++ 8 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 changelog/fix-8254-bnpl-pay-for-order diff --git a/changelog/fix-8254-bnpl-pay-for-order b/changelog/fix-8254-bnpl-pay-for-order new file mode 100644 index 00000000000..77c05e9e0b1 --- /dev/null +++ b/changelog/fix-8254-bnpl-pay-for-order @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +BNPL methods now work properly in Pay for Order when they are available. Default values are also provided when available. diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index 514f5364ffb..839e53a2ca7 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -121,6 +121,41 @@ function submitForm( jQueryForm ) { jQueryForm.removeClass( 'processing' ).submit(); } +/** + * Validates the contents of the address fields based on the requirements from BNPL payment methods. + * + * @param {Object} params The parameters to be sent to `createPaymentMethod`. + * @param {string} paymentMethodType The type of Stripe payment method to create. + * @return {boolean} True, if there are missing address fields. False, if the validation passes or is not applicable. + */ +function isMissingRequiredAddressFieldsForBNPL( params, paymentMethodType ) { + if ( [ 'afterpay_clearpay', 'affirm' ].includes( paymentMethodType ) ) { + return false; + } + const address = params?.billing_details?.address; + + if ( ! address ) { + return false; + } + + const requiredAddressFields = + paymentMethodType === 'affirm' + ? [ 'line1', 'state', 'city', 'postal_code', 'country' ] // Line2 is not required. + : [ 'line1', 'postal_code', 'country' ]; // City and State are not required in Afterpay. + + for ( const field of requiredAddressFields ) { + if ( + address[ field ] === '' || + address[ field ] === null || + typeof address[ field ] === 'undefined' + ) { + return true; + } + } + + return false; +} + /** * Creates a Stripe payment method by calling the Stripe API's createPaymentMethod with the provided elements * and billing details. The billing details are obtained from various form elements on the page. @@ -144,7 +179,7 @@ function createStripePaymentMethod( billing_details: { name: wcpayCustomerData.name || undefined, email: wcpayCustomerData.email, - address: { + address: wcpayCustomerData.address || { country: wcpayCustomerData.billing_country, }, }, @@ -192,6 +227,14 @@ function createStripePaymentMethod( }; } + if ( + getUPEConfig( 'isOrderPay' ) && + isMissingRequiredAddressFieldsForBNPL( params, paymentMethodType ) + ) { + // These payment methods don't accept an address object with partial information, so we just remove the object entirely. + delete params.billing_details.address; + } + return api .getStripeForUPE( paymentMethodType ) .createPaymentMethod( { elements, params: params } ); diff --git a/client/checkout/utils/test/upe.test.js b/client/checkout/utils/test/upe.test.js index 3b868dc0d49..c846f832d87 100644 --- a/client/checkout/utils/test/upe.test.js +++ b/client/checkout/utils/test/upe.test.js @@ -284,6 +284,8 @@ describe( 'UPE checkout utils', () => { if ( checkboxElement ) { checkboxElement.remove(); } + + delete window.wcpayCustomerData; } ); it( 'should not provide terms when cart does not contain subscriptions and the saving checkbox is unchecked', () => { @@ -355,6 +357,34 @@ describe( 'UPE checkout utils', () => { expect( upeSettings.terms.card ).toEqual( 'always' ); } ); + it( 'should define defaultValues when wcpayCustomerData is present', () => { + window.wcpayCustomerData = { + name: 'Test Person', + email: 'test@example.com', + billing_country: 'US', + }; + + const upeSettings = getUpeSettings(); + + expect( upeSettings.defaultValues ).toEqual( { + billingDetails: { + name: 'Test Person', + email: 'test@example.com', + address: { + country: 'US', + }, + }, + } ); + } ); + + it( 'should not define defaultValues if wcpayCustomerData is not present', () => { + window.wcpayCustomerData = null; + + const upeSettings = getUpeSettings(); + + expect( upeSettings.defaultValues ).toBeUndefined(); + } ); + function createCheckboxElementWhich( isChecked ) { // Create the checkbox element const checkboxElement = document.createElement( 'input' ); diff --git a/client/checkout/utils/upe.js b/client/checkout/utils/upe.js index ba4dd5a11f5..504f3495f07 100644 --- a/client/checkout/utils/upe.js +++ b/client/checkout/utils/upe.js @@ -119,6 +119,18 @@ export const getUpeSettings = () => { }; } + if ( window.wcpayCustomerData ) { + upeSettings.defaultValues = { + billingDetails: { + name: window.wcpayCustomerData.name, + email: window.wcpayCustomerData.email, + address: { + country: window.wcpayCustomerData.billing_country, + }, + }, + }; + } + return upeSettings; }; diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index 0b1cc59d9cf..214d3178bb8 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -376,16 +376,35 @@ public function payment_fields() { if ( ! wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) { $payment_fields = $this->get_payment_fields_js_config(); wp_enqueue_script( 'wcpay-upe-checkout' ); + /** + * We can't localize the script right away since at this point is not registered yet. + * We also need to make sure it that it only runs once (using a dummy action), even if + * there are multiple payment methods available; otherwise the data will be overwritten + * which is pointless. + * + * The same applies for `wcpayCustomerData` a few lines below. + */ add_action( 'wp_footer', function () use ( $payment_fields ) { - wp_localize_script( 'wcpay-upe-checkout', 'wcpay_upe_config', $payment_fields ); + if ( ! did_action( '__wcpay_upe_config_localized' ) ) { + wp_localize_script( 'wcpay-upe-checkout', 'wcpay_upe_config', $payment_fields ); + } + do_action( '__wcpay_upe_config_localized' ); } ); $prepared_customer_data = $this->customer_service->get_prepared_customer_data(); if ( ! empty( $prepared_customer_data ) ) { - wp_localize_script( 'wcpay-upe-checkout', 'wcpayCustomerData', $prepared_customer_data ); + add_action( + 'wp_footer', + function () use ( $prepared_customer_data ) { + if ( ! did_action( '__wcpay_customer_data_localized' ) ) { + wp_localize_script( 'wcpay-upe-checkout', 'wcpayCustomerData', $prepared_customer_data ); + } + do_action( '__wcpay_customer_data_localized' ); + } + ); } WC_Payments_Utils::enqueue_style( diff --git a/includes/class-wc-payments-customer-service.php b/includes/class-wc-payments-customer-service.php index e55d0b206c1..42d209fd3fd 100644 --- a/includes/class-wc-payments-customer-service.php +++ b/includes/class-wc-payments-customer-service.php @@ -531,6 +531,7 @@ public function get_prepared_customer_data() { $firstname = ''; $lastname = ''; $billing_country = ''; + $address = null; if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_id = absint( $wp->query_vars['order-pay'] ); @@ -541,6 +542,14 @@ public function get_prepared_customer_data() { $lastname = $order->get_billing_last_name(); $user_email = $order->get_billing_email(); $billing_country = $order->get_billing_country(); + $address = [ + 'city' => $order->get_billing_city(), + 'country' => $order->get_billing_country(), + 'line1' => $order->get_billing_address_1(), + 'line2' => $order->get_billing_address_2(), + 'postal_code' => $order->get_billing_postcode(), + 'state' => $order->get_billing_state(), + ]; } } @@ -560,6 +569,7 @@ public function get_prepared_customer_data() { 'name' => $firstname . ' ' . $lastname, 'email' => $user_email, 'billing_country' => $billing_country, + 'address' => $address, ]; } } diff --git a/includes/payment-methods/class-upe-payment-method.php b/includes/payment-methods/class-upe-payment-method.php index eaaad1bfbad..93bf0797088 100644 --- a/includes/payment-methods/class-upe-payment-method.php +++ b/includes/payment-methods/class-upe-payment-method.php @@ -171,11 +171,28 @@ public function is_enabled_at_checkout( string $account_country ) { // This part ensures that when payment limits for the currency declared, those will be respected (e.g. BNPLs). if ( [] !== $this->limits_per_currency ) { + $order = null; + if ( is_wc_endpoint_url( 'order-pay' ) ) { + $order = wc_get_order( absint( get_query_var( 'order-pay' ) ) ); + $order = is_a( $order, 'WC_Order' ) ? $order : null; + } + $currency = get_woocommerce_currency(); + if ( $order ) { + $currency = $order->get_currency(); + } + // If the currency limits are not defined, we allow the PM for now (gateway has similar validation for limits). - // Additionally, we don't engage with limits verification in no-checkout context (cart is not available or empty). - if ( isset( $this->limits_per_currency[ $currency ], WC()->cart ) ) { - $amount = WC_Payments_Utils::prepare_amount( WC()->cart->get_total( '' ), $currency ); + $total = null; + if ( $order ) { + $total = $order->get_total(); + } elseif ( isset( WC()->cart ) ) { + $total = WC()->cart->get_total( '' ); + } + + if ( isset( $this->limits_per_currency[ $currency ], WC()->cart ) && ! empty( $total ) ) { + $amount = WC_Payments_Utils::prepare_amount( $total, $currency ); + if ( $amount > 0 ) { $range = null; if ( isset( $this->limits_per_currency[ $currency ][ $account_country ] ) ) { diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index b638c07e6dd..bb90e4f4460 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -197,6 +197,21 @@ class WC_Payment_Gateway_WCPay_Test extends WCPAY_UnitTestCase { */ private $locale_backup; + + /** + * Backup of $wp->query_vars + * + * @var array + */ + private $wp_query_vars_backup; + + /** + * Backup of $wp_query->query_vars + * + * @var array + */ + private $wp_query_query_vars_backup; + /** * Pre-test setup */ @@ -281,6 +296,11 @@ public function set_up() { wcpay_get_test_container()->replace( OrderService::class, $mock_order_service ); $this->locale_backup = WC()->countries->get_country_locale(); + + global $wp; + global $wp_query; + $this->wp_query_vars_backup = $wp->query_vars; + $this->wp_query_query_vars_backup = $wp_query->query_vars; } /** @@ -318,6 +338,11 @@ public function tear_down() { wcpay_get_test_container()->reset_all_replacements(); WC()->session->set( 'wc_notices', [] ); WC()->countries->locale = $this->locale_backup; + + global $wp; + global $wp_query; + $wp->query_vars = $this->wp_query_vars_backup; + $wp_query->query_vars = $this->wp_query_query_vars_backup; } public function test_process_redirect_payment_intent_processing() { @@ -706,6 +731,77 @@ function ( $order ) { $this->assertFalse( $afterpay_method->is_enabled_at_checkout( 'US' ) ); } + public function test_payment_methods_enabled_based_on_currency_limits() { + WC_Helper_Site_Currency::$mock_site_currency = 'USD'; + + WC()->session->init(); + WC()->cart->empty_cart(); + // Total is 100 USD, which is above both payment methods (Affirm and AfterPay) minimums. + WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id(), 10 ); + WC()->cart->calculate_totals(); + + $affirm_method = $this->payment_methods['affirm']; + $afterpay_method = $this->payment_methods['afterpay_clearpay']; + + $this->assertTrue( $affirm_method->is_enabled_at_checkout( 'US' ) ); + $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + } + + public function test_payment_methods_disabled_based_on_currency_limits() { + WC_Helper_Site_Currency::$mock_site_currency = 'USD'; + + WC()->session->init(); + WC()->cart->empty_cart(); + // Total is 40 USD, which is below Affirm minimum. + WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id(), 4 ); + WC()->cart->calculate_totals(); + + $affirm_method = $this->payment_methods['affirm']; + $afterpay_method = $this->payment_methods['afterpay_clearpay']; + + $this->assertFalse( $affirm_method->is_enabled_at_checkout( 'US' ) ); + $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + } + + public function test_payment_methods_enabled_based_on_currency_limits_in_order_pay() { + global $wp; + global $wp_query; + + WC_Helper_Site_Currency::$mock_site_currency = 'USD'; + + // Total is 100 USD, which is above both payment methods (Affirm and AfterPay) minimums. + $order = WC_Helper_Order::create_order( 1, 100 ); + $order_id = $order->get_id(); + $wp->query_vars = [ 'order-pay' => strval( $order_id ) ]; + $wp_query->query_vars = [ 'order-pay' => strval( $order_id ) ]; + + $affirm_method = $this->payment_methods['affirm']; + $afterpay_method = $this->payment_methods['afterpay_clearpay']; + + $this->assertTrue( $affirm_method->is_enabled_at_checkout( 'US' ) ); + $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + } + + public function test_payment_methods_disabled_based_on_currency_limits_in_order_pay() { + global $wp; + global $wp_query; + + WC_Helper_Site_Currency::$mock_site_currency = 'USD'; + + // Total is 40 USD, which is below Affirm minimum. + $order = WC_Helper_Order::create_order( 1, 40 ); + $order_id = $order->get_id(); + $wp->query_vars = [ 'order-pay' => strval( $order_id ) ]; + $wp_query->query_vars = [ 'order-pay' => strval( $order_id ) ]; + $order->set_currency( 'USD' ); + + $affirm_method = $this->payment_methods['affirm']; + $afterpay_method = $this->payment_methods['afterpay_clearpay']; + + $this->assertFalse( $affirm_method->is_enabled_at_checkout( 'US' ) ); + $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + } + public function test_only_valid_payment_methods_returned_for_currency() { $card_method = $this->payment_methods['card']; $giropay_method = $this->payment_methods['giropay'];