diff --git a/changelog.txt b/changelog.txt index ab03cd2a7..2b3ea4698 100644 --- a/changelog.txt +++ b/changelog.txt @@ -5,6 +5,7 @@ * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. * Fix - Fix empty error message for Express Payments when order creation fails. +* Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. diff --git a/client/classic/upe/deferred-intent.js b/client/classic/upe/deferred-intent.js index f1422db1e..aee8f9d37 100644 --- a/client/classic/upe/deferred-intent.js +++ b/client/classic/upe/deferred-intent.js @@ -170,7 +170,8 @@ jQuery( function ( $ ) { function maybeConfirmVoucherOrWalletPayment() { if ( getStripeServerData()?.isOrderPay || - getStripeServerData()?.isCheckout + getStripeServerData()?.isCheckout || + getStripeServerData()?.isChangingPayment ) { if ( window.location.hash.startsWith( '#wc-stripe-voucher-' ) ) { confirmVoucherPayment( @@ -184,7 +185,8 @@ jQuery( function ( $ ) { ) { confirmWalletPayment( api, - getStripeServerData()?.isOrderPay + getStripeServerData()?.isOrderPay || + getStripeServerData()?.isChangingPayment ? $( '#order_review' ) : $( 'form.checkout' ) ); diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 4611eda5b..d635b7541 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -394,7 +394,7 @@ export const confirmVoucherPayment = async ( api, jQueryForm ) => { * * When processing a payment for a wallet payment method on the checkout or order pay page, * the process_payment_with_deferred_intent() function redirects the customer to a URL - * formatted with: #wc-stripe-wallet-:::. + * formatted with: #wc-stripe-wallet-::::. * * This function, which is hooked onto the hashchanged event, checks if the URL contains the data we need to process the wallet payment. * @@ -410,7 +410,7 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } const partials = window.location.href.match( - /#wc-stripe-wallet-(.+):(.+):(.+):(.+)$/ + /#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+)$/ ); if ( ! partials ) { @@ -426,7 +426,7 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { ); const orderId = partials[ 1 ]; - const clientSecret = partials[ 3 ]; + const clientSecret = partials[ 4 ]; // Verify the request using the data added to the URL. if ( @@ -438,7 +438,8 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } const paymentMethodType = partials[ 2 ]; - const returnURL = decodeURIComponent( partials[ 4 ] ); + const intentType = partials[ 3 ]; + const returnURL = decodeURIComponent( partials[ 5 ] ); try { // Confirm the payment to tell Stripe to display the modal to the customer. @@ -456,11 +457,19 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } ); break; case 'cashapp': - confirmPayment = await api - .getStripe() - .confirmCashappPayment( clientSecret, { - return_url: returnURL, - } ); + if ( intentType === 'setup_intent' ) { + confirmPayment = await api + .getStripe() + .confirmCashappSetup( clientSecret, { + return_url: returnURL, + } ); + } else { + confirmPayment = await api + .getStripe() + .confirmCashappPayment( clientSecret, { + return_url: returnURL, + } ); + } break; default: // eslint-disable-next-line no-console @@ -472,15 +481,18 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { throw confirmPayment.error; } - if ( confirmPayment.paymentIntent.last_payment_error ) { - throw new Error( - confirmPayment.paymentIntent.last_payment_error.message - ); + const intentObject = + intentType === 'setup_intent' + ? confirmPayment.setupIntent + : confirmPayment.paymentIntent; + + if ( intentObject.last_payment_error ) { + throw new Error( intentObject.last_payment_error.message ); } // Do not redirect to the order received page if the modal is closed without payment. // Otherwise redirect to the order received page. - if ( confirmPayment.paymentIntent.status !== 'requires_action' ) { + if ( intentObject.status !== 'requires_action' ) { window.location.href = returnURL; } } catch ( error ) { diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 57e9af1b8..7890e515e 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -960,7 +960,7 @@ public function is_mandate_data_required( $selected_payment_type, $is_using_save * * @throws WC_Stripe_Exception If the create intent call returns with an error. * - * @return array + * @return stdClass */ public function create_and_confirm_setup_intent( $payment_information ) { $request = [ @@ -980,7 +980,7 @@ public function create_and_confirm_setup_intent( $payment_information ) { $request = $this->add_mandate_data( $request ); } - // For voucher payment methods type like Boleto, Oxxo & Multibanco, we shouldn't confirm the intent immediately as this is done on the front-end when displaying the voucher to the customer. + // For voucher payment methods type like Boleto, Oxxo, Multibanco, and Cash App, we shouldn't confirm the intent immediately as this is done on the front-end when displaying the voucher to the customer. // When the intent is confirmed, Stripe sends a webhook to the store which puts the order on-hold, which we only want to happen after successfully displaying the voucher. if ( $this->is_delayed_confirmation_required( $request['payment_method_types'] ) ) { $request['confirm'] = 'false'; diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 72bec865c..682b66248 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -418,6 +418,14 @@ public function javascript_params() { $stripe_params['currency'] = $currency; if ( parent::is_valid_pay_for_order_endpoint() || $is_change_payment_method ) { + $order_id = absint( get_query_var( 'order-pay' ) ); + $order = wc_get_order( $order_id ); + + // Make billing country available for subscriptions as well, so country-restricted payment methods can be shown. + if ( is_a( $order, 'WC_Order' ) ) { + $stripe_params['customerData'] = [ 'billing_country' => $order->get_billing_country() ]; + } + if ( $this->is_subscriptions_enabled() && $is_change_payment_method ) { $stripe_params['isChangingPayment'] = true; $stripe_params['addPaymentReturnURL'] = wp_sanitize_redirect( esc_url_raw( home_url( add_query_arg( [] ) ) ) ); @@ -430,15 +438,13 @@ public function javascript_params() { return $stripe_params; } - $order_id = absint( get_query_var( 'order-pay' ) ); $stripe_params['orderId'] = $order_id; $stripe_params['isOrderPay'] = true; - $order = wc_get_order( $order_id ); + // Additional params for order pay page, when the order was successfully loaded. if ( is_a( $order, 'WC_Order' ) ) { $order_currency = $order->get_currency(); $stripe_params['currency'] = $order_currency; - $stripe_params['customerData'] = [ 'billing_country' => $order->get_billing_country() ]; $stripe_params['cartTotal'] = WC_Stripe_Helper::get_stripe_amount( $order->get_total(), $order_currency ); $stripe_params['orderReturnURL'] = esc_url_raw( add_query_arg( @@ -450,7 +456,6 @@ public function javascript_params() { $this->get_return_url( $order ) ) ); - } } elseif ( is_wc_endpoint_url( 'add-payment-method' ) ) { $stripe_params['cartTotal'] = 0; @@ -771,12 +776,13 @@ private function process_payment_with_deferred_intent( int $order_id ) { $this->validate_selected_payment_method_type( $payment_information, $order->get_billing_country() ); - $payment_needed = $this->is_payment_needed( $order->get_id() ); - $payment_method_id = $payment_information['payment_method']; - $payment_method_details = $payment_information['payment_method_details']; - $selected_payment_type = $payment_information['selected_payment_type']; - $upe_payment_method = $this->payment_methods[ $selected_payment_type ] ?? null; - $response_args = []; + $payment_needed = $this->is_payment_needed( $order->get_id() ); + $payment_method_id = $payment_information['payment_method']; + $payment_method_details = $payment_information['payment_method_details']; + $selected_payment_type = $payment_information['selected_payment_type']; + $is_using_saved_payment_method = $payment_information['is_using_saved_payment_method']; + $upe_payment_method = $this->payment_methods[ $selected_payment_type ] ?? null; + $response_args = []; // Make sure that we attach the payment method and the customer ID to the order meta data. $this->set_payment_method_id_for_order( $order, $payment_method_id ); @@ -794,7 +800,7 @@ private function process_payment_with_deferred_intent( int $order_id ) { $this->maybe_disallow_prepaid_card( $payment_method ); // Update saved payment method to include billing details. - if ( $payment_information['is_using_saved_payment_method'] ) { + if ( $is_using_saved_payment_method ) { $this->update_saved_payment_method( $payment_method_id, $order ); } @@ -807,7 +813,24 @@ private function process_payment_with_deferred_intent( int $order_id ) { // Create a payment intent, or update an existing one associated with the order. $payment_intent = $this->process_payment_intent_for_order( $order, $payment_information ); + } elseif ( $is_using_saved_payment_method && 'cashapp' === $selected_payment_type ) { + // If the payment method is Cash App Pay, the order has no cost, and a saved payment method is used, mark the order as paid. + $this->maybe_update_source_on_subscription_order( + $order, + (object) [ + 'payment_method' => $payment_information['payment_method'], + 'customer' => $payment_information['customer'], + ], + $this->get_upe_gateway_id_for_order( $upe_payment_method ) + ); + $order->payment_complete(); + + return [ + 'result' => 'success', + 'redirect' => $this->get_return_url( $order ), + ]; } else { + // Create a setup intent, or update an existing one associated with the order. $payment_intent = $this->process_setup_intent_for_order( $order, $payment_information ); } @@ -819,7 +842,7 @@ private function process_payment_with_deferred_intent( int $order_id ) { $payment_method_details, $selected_payment_type ); - } elseif ( $payment_information['is_using_saved_payment_method'] ) { + } elseif ( $is_using_saved_payment_method ) { $this->maybe_update_source_on_subscription_order( $order, (object) [ @@ -857,11 +880,12 @@ private function process_payment_with_deferred_intent( int $order_id ) { rawurlencode( $redirect ) ); } elseif ( isset( $payment_intent->payment_method_types ) && count( array_intersect( [ 'wechat_pay', 'cashapp' ], $payment_intent->payment_method_types ) ) !== 0 ) { - // For Wallet payment method types (CashApp/WeChat Pay), redirect the customer to a URL hash formatted #wc-stripe-wallet-{order_id}:{payment_method_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the modal. + // For Wallet payment method types (CashApp/WeChat Pay), redirect the customer to a URL hash formatted #wc-stripe-wallet-{order_id}:{payment_method_type}:{payment_intent_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the modal. $redirect = sprintf( - '#wc-stripe-wallet-%s:%s:%s:%s', + '#wc-stripe-wallet-%s:%s:%s:%s:%s', $order_id, $payment_information['selected_payment_type'], + $payment_intent->object, $payment_intent->client_secret, rawurlencode( $redirect ) ); @@ -1613,10 +1637,7 @@ public function set_payment_method_title_for_order( $order, $payment_method_type $order->save(); // Update the subscription's purchased in this order with the payment method ID. - if ( isset( $this->payment_methods[ $payment_method_type ] ) ) { - $payment_method_instance = $this->payment_methods[ $payment_method_type ]; - $this->update_subscription_payment_method_from_order( $order, $this->get_upe_gateway_id_for_order( $payment_method_instance ) ); - } + $this->update_subscription_payment_method_from_order( $order, $this->get_upe_gateway_id_for_order( $payment_method ) ); } /** diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php index 430eba511..e872d1911 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-cash-app-pay.php @@ -40,14 +40,6 @@ public function __construct() { // Cash App Pay supports subscriptions. Init subscriptions so it can process subscription payments. $this->maybe_init_subscriptions(); - /** - * Cash App Pay is incapable of processing zero amount payments with saved payment methods. - * - * This is because setup intents with a saved payment method (token) fail. While we wait for a solution to this issue, we - * disable customer's changing the payment method to Cash App Pay as that would result in a $0 set up intent. - */ - $this->supports = array_diff( $this->supports, [ 'subscription_payment_method_change_customer' ] ); - add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'order_received_text_for_wallet_failure' ], 10, 2 ); } @@ -71,28 +63,6 @@ public function get_retrievable_type() { return $this->get_id(); } - /** - * Determines whether Cash App Pay is enabled at checkout. - * - * @param int $order_id The order ID. - * @param string $account_domestic_currency The account's default currency. - * - * @return bool Whether Cash App Pay is enabled at checkout. - */ - public function is_enabled_at_checkout( $order_id = null, $account_domestic_currency = null ) { - /** - * Cash App Pay is incapable of processing zero amount payments with saved payment methods. - * - * This is because setup intents with a saved payment method (token) fail. While we wait for a solution to this issue, we - * disable Cash App Pay for zero amount orders. - */ - if ( ! is_add_payment_method_page() && $this->get_current_order_amount() <= 0 ) { - return false; - } - - return parent::is_enabled_at_checkout( $order_id, $account_domestic_currency ); - } - /** * Creates a Cash App Pay payment token for the customer. * @@ -130,10 +100,10 @@ public function order_received_text_for_wallet_failure( $text, $order ) { $redirect_status = wc_clean( wp_unslash( $_GET['redirect_status'] ) ); } if ( $order && $this->id === $order->get_payment_method() && 'failed' === $redirect_status ) { - $text = '

'; + $text = '

'; $text .= esc_html( 'Unfortunately your order cannot be processed as the payment method has declined your transaction. Please attempt your purchase again.' ); - $text .= '

'; - $text .= '

'; + $text .= '

'; + $text .= '

'; $text .= '' . esc_html( 'Pay' ) . ''; if ( is_user_logged_in() ) { $text .= '' . esc_html( 'My account' ) . ''; diff --git a/readme.txt b/readme.txt index d0b85c06c..5850861e2 100644 --- a/readme.txt +++ b/readme.txt @@ -133,6 +133,7 @@ If you get stuck, you can ask for help in the Plugin Forum. * Tweak - Update the Apple Pay domain registration flow to use the new Stripe API endpoint. * Fix - Resolve an error for checkout block where 'wc_stripe_upe_params' is undefined due to the script registering the variable not being loaded yet. * Fix - Fix empty error message for Express Payments when order creation fails. +* Fix - Fix multiple issues related to the reuse of Cash App Pay tokens (as a saved payment method) when subscribing. = 8.7.0 - xxxx-xx-xx = * Fix - Prevent duplicate failed-order emails from being sent. diff --git a/tests/phpunit/helpers/class-wc-helper-order.php b/tests/phpunit/helpers/class-wc-helper-order.php index cfa4bd587..ffd967439 100644 --- a/tests/phpunit/helpers/class-wc-helper-order.php +++ b/tests/phpunit/helpers/class-wc-helper-order.php @@ -40,10 +40,11 @@ public static function delete_order( $order_id ) { * * @param int $customer_id The ID of the customer the order is for. * @param WC_Product $product The product to add to the order. + * @param array $order_props Order properties. * * @return WC_Order */ - public static function create_order( $customer_id = 1, $product = null ) { + public static function create_order( $customer_id = 1, $product = null, $order_props = [] ) { if ( ! is_a( $product, 'WC_Product' ) ) { $product = WC_Helper_Product::create_simple_product(); @@ -115,6 +116,12 @@ public static function create_order( $customer_id = 1, $product = null ) { $order->set_cart_tax( 0 ); $order->set_shipping_tax( 0 ); $order->set_total( 50 ); // 4 x $10 simple helper product + + // Additional order properties. + foreach ( $order_props as $key => $value ) { + $order->{"set_$key"}( $value ); + } + $order->save(); return $order; diff --git a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php index 04581d729..67e1f2416 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -155,7 +155,7 @@ public function set_up() { ); $this->mock_gateway->intent_controller = $this->getMockBuilder( WC_Stripe_Intent_Controller::class ) - ->setMethods( [ 'create_and_confirm_payment_intent', 'update_and_confirm_payment_intent' ] ) + ->setMethods( [ 'create_and_confirm_payment_intent', 'update_and_confirm_payment_intent', 'create_and_confirm_setup_intent' ] ) ->getMock(); $this->mock_stripe_customer = $this->getMockBuilder( WC_Stripe_Customer::class ) @@ -468,10 +468,16 @@ public function test_process_payment_deferred_intent_with_required_action_return /** * Test Wallet checkout process_payment flow with deferred intent. + * + * @param string $payment_method Payment method to test. + * @param bool $free_order Whether the order is free. + * @param bool $saved_token Whether the payment method is saved. + * @dataProvider provide_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response + * @throws WC_Data_Exception When setting order payment method fails. */ - public function test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response() { + public function test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response( $payment_method, $free_order = false, $saved_token = false ) { $customer_id = 'cus_mock'; - $order = WC_Helper_Order::create_order(); + $order = WC_Helper_Order::create_order( 1, null, [ 'total' => $free_order ? 0 : 50 ] ); $order_id = $order->get_id(); // Set payment gateway. @@ -482,6 +488,7 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa $mock_intent = (object) wp_parse_args( [ 'status' => 'requires_action', + 'object' => 'payment_intent', 'data' => [ (object) [ 'id' => $order_id, @@ -490,7 +497,7 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa ], ], 'payment_method' => 'pm_mock', - 'payment_method_types' => [ 'wechat_pay' ], + 'payment_method_types' => [ $payment_method ], 'charges' => (object) [ 'total_count' => 0, // Intents requiring SCA verification respond with no charges. 'data' => [], @@ -501,16 +508,30 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa // Set the appropriate POST flag to trigger a deferred intent request. $_POST = [ - 'payment_method' => 'stripe_wechat_pay', + 'payment_method' => 'stripe_' . $payment_method, 'wc-stripe-payment-method' => 'pm_mock', 'wc-stripe-is-deferred-intent' => '1', ]; + if ( $saved_token ) { + $token = WC_Helper_Token::create_token( 'pm_mock' ); + $token->set_gateway_id( 'stripe_' . $payment_method ); + $token->save(); + + $_POST[ 'wc-stripe_' . $payment_method . '-payment-token' ] = (string) $token->get_id(); + } + $this->mock_gateway->intent_controller - ->expects( $this->once() ) + ->expects( $free_order ? $this->never() : $this->once() ) ->method( 'create_and_confirm_payment_intent' ) ->willReturn( $mock_intent ); + $create_and_confirm_setup_intent_num_calls = $free_order && ! ( $saved_token && 'cashapp' === $payment_method ) ? 1 : 0; + $this->mock_gateway->intent_controller + ->expects( $this->exactly( $create_and_confirm_setup_intent_num_calls ) ) + ->method( 'create_and_confirm_setup_intent' ) + ->willReturn( $mock_intent ); + $this->mock_gateway ->expects( $this->once() ) ->method( 'get_stripe_customer_id' ) @@ -518,19 +539,50 @@ public function test_process_payment_deferred_intent_with_required_action_for_wa // We only use this when handling mandates. $this->mock_gateway - ->expects( $this->exactly( 2 ) ) + ->expects( $saved_token ? $this->never() : ( $free_order ? $this->once() : $this->exactly( 2 ) ) ) ->method( 'get_latest_charge_from_intent' ) ->willReturn( null ); $this->mock_gateway - ->expects( $this->never() ) + ->expects( $saved_token ? $this->once() : $this->never() ) ->method( 'update_saved_payment_method' ); $response = $this->mock_gateway->process_payment( $order_id ); $return_url = self::MOCK_RETURN_URL; + if ( $saved_token ) { + $expected_redirect_url = '/' . self::MOCK_RETURN_URL . '/'; + } else { + $expected_redirect_url = "/#wc-stripe-wallet-{$order_id}:{$payment_method}:{$mock_intent->object}:{$mock_intent->client_secret}:{$return_url}/"; + } + $this->assertEquals( 'success', $response['result'] ); - $this->assertMatchesRegularExpression( "/#wc-stripe-wallet-{$order_id}:wechat_pay:{$mock_intent->client_secret}:{$return_url}/", $response['redirect'] ); + $this->assertMatchesRegularExpression( $expected_redirect_url, $response['redirect'] ); + } + + /** + * Provider for `test_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response`. + * + * @return array + */ + public function provide_process_payment_deferred_intent_with_required_action_for_wallet_returns_valid_response() { + return [ + 'wechat pay / default amount' => [ + 'payment method' => 'wechat_pay', + ], + 'cashapp / default amount' => [ + 'payment method' => 'cashapp', + ], + 'cashapp / free' => [ + 'payment method' => 'cashapp', + 'free order' => true, + ], + 'cashapp / free / saved token' => [ + 'payment method' => 'cashapp', + 'free order' => true, + 'saved token' => true, + ], + ]; } /**