diff --git a/changelog.txt b/changelog.txt index cf4d3c188..1c1c7177d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -3,6 +3,9 @@ = 8.8.1 - xxxx-xx-xx = * Tweak - Disables APMs when using the legacy checkout experience due Stripe deprecation by October 29, 2024. * Fix - Prevent marking orders on-hold with order note "Process order to take payment" when the payment has failed. +* Fix - Prevent subscriptions from being marked as "Pending" when a customer attempts to change their payment method to a declining card. +* Fix - Delay updating the subscription's payment method until after the intent is confirmed when using the new checkout experience. +* Fix - Display a success notice to customers after successfully changing their subscription payment method to a card that required 3DS authentication. = 8.8.0 - 2024-10-17 = * Fix - Update URL and path constants to support use of symlinked plugin. diff --git a/client/classic/upe/payment-processing.js b/client/classic/upe/payment-processing.js index 12bb3bed8..bd05d76a9 100644 --- a/client/classic/upe/payment-processing.js +++ b/client/classic/upe/payment-processing.js @@ -405,6 +405,7 @@ export const confirmVoucherPayment = async ( api, jQueryForm ) => { */ export const confirmWalletPayment = async ( api, jQueryForm ) => { const isOrderPay = getStripeServerData()?.isOrderPay; + const isChangingPayment = getStripeServerData()?.isChangingPayment; // The Order Pay page does a hard refresh when the hash changes, so we need to block the UI again. if ( isOrderPay ) { @@ -412,7 +413,7 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { } const partials = window.location.href.match( - /#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+)$/ + /#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+):(.+)$/ ); if ( ! partials ) { @@ -495,7 +496,27 @@ export const confirmWalletPayment = async ( api, jQueryForm ) => { // Do not redirect to the order received page if the modal is closed without payment. // Otherwise redirect to the order received page. if ( intentObject.status !== 'requires_action' ) { - window.location.href = returnURL; + if ( ! isChangingPayment ) { + window.location.href = returnURL; + } + + // If we're changing a subscription's payment method, there's an extra step needed. + // We need to confirm the change payment intent via the confirm_change_payment AJAX request and then redirect to the return URL. + const response = await api.request( + api.getAjaxUrl( 'confirm_change_payment' ), + { + order_id: orderId, + intent_id: intentObject.id, + payment_method_id: intentObject.payment_method || null, + _ajax_nonce: partials[ 6 ], + } + ); + + if ( response.success ) { + window.location.href = response.data.return_url; + } else { + throw new Error( response.data.error.message ); + } } } catch ( error ) { showErrorCheckout( error.message ); diff --git a/includes/class-wc-stripe-intent-controller.php b/includes/class-wc-stripe-intent-controller.php index 9e51cb8a3..9ae2ed1b5 100644 --- a/includes/class-wc-stripe-intent-controller.php +++ b/includes/class-wc-stripe-intent-controller.php @@ -1086,7 +1086,7 @@ public function confirm_change_payment_from_setup_intent_ajax() { throw new WC_Stripe_Exception( 'missing-nonce', __( 'CSRF verification failed.', 'woocommerce-gateway-stripe' ) ); } - if ( ! function_exists( 'wcs_is_subscription' ) ) { + if ( ! function_exists( 'wcs_is_subscription' ) || ! class_exists( 'WC_Subscriptions_Change_Payment_Gateway' ) ) { throw new WC_Stripe_Exception( 'subscriptions_not_found', __( "We're not able to process this subscription change payment request payment. Please try again later.", 'woocommerce-gateway-stripe' ) ); } @@ -1104,8 +1104,23 @@ public function confirm_change_payment_from_setup_intent_ajax() { } $gateway = $this->get_upe_gateway(); - $gateway->create_token_from_setup_intent( $setup_intent_id, $subscription->get_user() ); + $token = $gateway->create_token_from_setup_intent( $setup_intent_id, $subscription->get_user() ); + $notice = __( 'Payment method updated.', 'woocommerce-gateway-stripe' ); + // Manually update the payment method for the subscription now that we have confirmed the payment method. + WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $subscription, $token->get_gateway_id() ); + + // Set the new Stripe payment method ID and customer ID on the subscription. + $customer = new WC_Stripe_Customer( wp_get_current_user()->ID ); + $gateway->set_customer_id_for_order( $subscription, $customer->get_id() ); + $gateway->set_payment_method_id_for_order( $subscription, $token->get_token() ); + + // Check if the subscription has the delayed update all flag and attempt to update all subscriptions after the intent has been confirmed. If successful, display the "updated all subscriptions" notice. + if ( WC_Subscriptions_Change_Payment_Gateway::will_subscription_update_all_payment_methods( $subscription ) && WC_Subscriptions_Change_Payment_Gateway::update_all_payment_methods_from_subscription( $subscription, $token->get_gateway_id() ) ) { + $notice = __( 'Payment method updated for all your current subscriptions.', 'woocommerce-gateway-stripe' ); + } + + wc_add_notice( $notice ); wp_send_json_success( [ 'return_url' => $subscription->get_view_order_url(), diff --git a/includes/compat/trait-wc-stripe-subscriptions.php b/includes/compat/trait-wc-stripe-subscriptions.php index 0d40491d3..357ea0cd4 100644 --- a/includes/compat/trait-wc-stripe-subscriptions.php +++ b/includes/compat/trait-wc-stripe-subscriptions.php @@ -91,6 +91,8 @@ public function maybe_init_subscriptions() { // Disable editing for Indian subscriptions with mandates. Those need to be recreated as mandates does not support upgrades (due fixed amounts). add_filter( 'wc_order_is_editable', [ $this, 'disable_subscription_edit_for_india' ], 10, 2 ); + + add_filter( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', [ $this, 'update_payment_after_deferred_intent' ], 10, 3 ); } /** @@ -244,6 +246,103 @@ public function process_change_subscription_payment_method( $order_id ) { } } + /** + * Process the payment method change with deferred intent. + * + * @param int $subscription_id + * + * @return array + */ + public function process_change_subscription_payment_with_deferred_intent( $subscription_id ) { + $subscription = wcs_get_subscription( $subscription_id ); + + if ( ! $subscription ) { + return [ + 'result' => 'failure', + 'redirect' => '', + ]; + } + + try { + $payment_information = $this->prepare_payment_information_from_request( $subscription ); + + $this->validate_selected_payment_method_type( $payment_information, $subscription->get_billing_country() ); + + $payment_method_id = $payment_information['payment_method']; + $selected_payment_type = $payment_information['selected_payment_type']; + $upe_payment_method = $this->payment_methods[ $selected_payment_type ] ?? null; + + // Retrieve the payment method object from Stripe. + $payment_method = $this->stripe_request( 'payment_methods/' . $payment_method_id ); + + // Throw an exception when the payment method is a prepaid card and it's disallowed. + $this->maybe_disallow_prepaid_card( $payment_method ); + + // Create a setup intent, or update an existing one associated with the order. + $payment_intent = $this->process_setup_intent_for_order( $subscription, $payment_information ); + + // Handle saving the payment method in the store. + if ( $payment_information['save_payment_method_to_store'] && $upe_payment_method && $upe_payment_method->get_id() === $upe_payment_method->get_retrievable_type() ) { + $this->handle_saving_payment_method( + $subscription, + $payment_information['payment_method_details'], + $selected_payment_type + ); + } + + $redirect = $this->get_return_url( $subscription ); + $new_payment_method = $this->get_upe_gateway_id_for_order( $upe_payment_method ); + + // If the payment intent requires confirmation or action, redirect the customer to confirm the intent. + if ( in_array( $payment_intent->status, [ 'requires_confirmation', 'requires_action' ], true ) ) { + // Because we're filtering woocommerce_subscriptions_update_payment_via_pay_shortcode, we need to manually set this delayed update all flag here. + if ( isset( $_POST['update_all_subscriptions_payment_method'] ) && wc_clean( wp_unslash( $_POST['update_all_subscriptions_payment_method'] ) ) ) { + $subscription->update_meta_data( '_delayed_update_payment_method_all', $new_payment_method ); + $subscription->save(); + } + + wp_safe_redirect( $this->get_redirect_url( $redirect, $payment_intent, $payment_information, $subscription, false ) ); + exit; + } else { + // Update the payment method for the subscription. + WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $subscription, $new_payment_method ); + + // Attach the new payment method ID and the customer ID to the subscription on success. + $this->set_payment_method_id_for_order( $subscription, $payment_method_id ); + $this->set_customer_id_for_order( $subscription, $payment_information['customer'] ); + + // Trigger wc_stripe_change_subs_payment_method_success action hook to preserve backwards compatibility, see process_change_subscription_payment_method(). + do_action( + 'wc_stripe_change_subs_payment_method_success', + $payment_information['payment_method'], + (object) [ + 'token_id' => false !== $payment_information['token'] ? $payment_information['token']->get_id() : false, + 'customer' => $payment_information['customer'], + 'source' => null, + 'source_object' => $payment_method, + 'payment_method' => $payment_information['payment_method'], + ] + ); + + // Because this new payment does not require action/confirmation, remove this filter so that WC_Subscriptions_Change_Payment_Gateway proceeds to update all subscriptions if flagged. + remove_filter( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', [ $this, 'update_payment_after_deferred_intent' ], 10 ); + } + + return [ + 'result' => 'success', + 'redirect' => $redirect, + ]; + } catch ( WC_Stripe_Exception $e ) { + wc_add_notice( $e->getLocalizedMessage(), 'error' ); + WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); + + return [ + 'result' => 'failure', + 'redirect' => '', + ]; + } + } + /** * Scheduled_subscription_payment function. * @@ -1059,4 +1158,24 @@ public function disable_subscription_edit_for_india( $editable, $order ) { return $editable; } + + /** + * When handling a subscription change payment method request with deferred intents, + * don't immediately update the subscription's payment method to Stripe until we've created and confirmed the setup intent. + * + * For purchases with a 3DS card specifically, we don't want to update the payment method on the subscription until after the customer has authenticated. + * + * @param bool $update_payment_method Whether to update the payment method. + * @param string $new_payment_method The new payment method. + * @param WC_Subscription $subscription The subscription. + * + * @return bool + */ + public function update_payment_after_deferred_intent( $update_payment_method, $new_payment_method, $subscription ) { + if ( ! $this->is_changing_payment_method_for_subscription() || $new_payment_method !== $this->id || empty( $_POST['wc-stripe-is-deferred-intent'] ) ) { + return $update_payment_method; + } + + return 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 d3dd895c4..5fcb221d5 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -771,6 +771,10 @@ public function process_payment( $order_id, $retry = true, $force_save_source = * @return array An array with the result of the payment processing, and a redirect URL on success. */ private function process_payment_with_deferred_intent( int $order_id ) { + if ( $this->is_changing_payment_method_for_subscription() ) { + return $this->process_change_subscription_payment_with_deferred_intent( $order_id ); + } + $order = wc_get_order( $order_id ); try { @@ -893,19 +897,6 @@ private function process_payment_with_deferred_intent( int $order_id ) { if ( $charge ) { $this->process_response( $charge, $order ); } - } elseif ( $this->is_changing_payment_method_for_subscription() ) { - // Trigger wc_stripe_change_subs_payment_method_success action hook to preserve backwards compatibility, see process_change_subscription_payment_method(). - do_action( - 'wc_stripe_change_subs_payment_method_success', - $payment_information['payment_method'], - (object) [ - 'token_id' => false !== $payment_information['token'] ? $payment_information['token']->get_id() : false, - 'customer' => $payment_information['customer'], - 'source' => null, - 'source_object' => $payment_method, - 'payment_method' => $payment_information['payment_method'], - ] - ); } elseif ( in_array( $payment_intent->status, self::SUCCESSFUL_INTENT_STATUS, true ) ) { if ( ! $this->has_pre_order( $order ) ) { $order->payment_complete(); @@ -1943,7 +1934,7 @@ private function process_payment_intent_for_order( WC_Order $order, array $payme * * @return stdClass */ - private function process_setup_intent_for_order( WC_Order $order, array $payment_information ) { + protected function process_setup_intent_for_order( WC_Order $order, array $payment_information ) { $setup_intent = $this->intent_controller->create_and_confirm_setup_intent( $payment_information ); if ( ! empty( $setup_intent->error ) ) { @@ -1975,7 +1966,7 @@ private function process_setup_intent_for_order( WC_Order $order, array $payment * @return array An array containing the payment information for processing a payment intent. * @throws WC_Stripe_Exception When there's an error retrieving the payment information. */ - private function prepare_payment_information_from_request( WC_Order $order ) { + protected function prepare_payment_information_from_request( WC_Order $order ) { $selected_payment_type = $this->get_selected_payment_method_type_from_request(); $capture_method = empty( $this->get_option( 'capture' ) ) || $this->get_option( 'capture' ) === 'yes' ? 'automatic' : 'manual'; // automatic | manual. $currency = strtolower( $order->get_currency() ); @@ -2184,7 +2175,7 @@ private function get_selected_payment_method_type_from_request() { * @param stdClass $payment_method_object The payment method object retrieved from Stripe. * @param string $payment_method_type The payment method type, like `card`, `sepa_debit`, etc. */ - private function handle_saving_payment_method( WC_Order $order, $payment_method_object, string $payment_method_type ) { + protected function handle_saving_payment_method( WC_Order $order, $payment_method_object, string $payment_method_type ) { $user = $this->get_user_from_order( $order ); $customer = new WC_Stripe_Customer( $user->ID ); $customer->clear_cache(); @@ -2219,7 +2210,7 @@ private function handle_saving_payment_method( WC_Order $order, $payment_method_ * @param WC_Order $order The order. * @param string $payment_method_id The value to be set. */ - private function set_payment_method_id_for_order( WC_Order $order, string $payment_method_id ) { + public function set_payment_method_id_for_order( WC_Order $order, string $payment_method_id ) { // Save the payment method id as `source_id`, because we use both `sources` and `payment_methods` APIs. $order->update_meta_data( '_stripe_source_id', $payment_method_id ); $order->save_meta_data(); @@ -2228,10 +2219,12 @@ private function set_payment_method_id_for_order( WC_Order $order, string $payme /** * Set the payment metadata for customer id. * + * Set to public so it can be called from confirm_change_payment_from_setup_intent_ajax() + * * @param WC_Order $order The order. * @param string $customer_id The value to be set. */ - private function set_customer_id_for_order( WC_Order $order, string $customer_id ) { + public function set_customer_id_for_order( WC_Order $order, string $customer_id ) { $order->update_meta_data( '_stripe_customer_id', $customer_id ); $order->save_meta_data(); } @@ -2275,7 +2268,7 @@ private function get_customer_id_for_order( WC_Order $order ): string { * * @throws WC_Stripe_Exception When the payment method type is not allowed in the given country. */ - private function validate_selected_payment_method_type( $payment_information, $billing_country ) { + protected function validate_selected_payment_method_type( $payment_information, $billing_country ) { $invalid_method_message = __( 'The selected payment method type is invalid.', 'woocommerce-gateway-stripe' ); // No payment method type was provided. @@ -2481,7 +2474,7 @@ private function should_upe_payment_method_show_save_option( $payment_method ) { * @param WC_Stripe_UPE_Payment_Method $payment_method The UPE payment method instance. * @return string The gateway ID to set on the subscription/order. */ - private function get_upe_gateway_id_for_order( $payment_method ) { + protected function get_upe_gateway_id_for_order( $payment_method ) { $token_gateway_type = $payment_method->get_retrievable_type(); if ( WC_Stripe_Payment_Methods::CARD !== $token_gateway_type ) { @@ -2545,7 +2538,7 @@ private function is_refund_request() { * @param $payment_needed bool Whether payment is needed. * @return string The redirect URL. */ - private function get_redirect_url( $return_url, $payment_intent, $payment_information, $order, $payment_needed ) { + protected function get_redirect_url( $return_url, $payment_intent, $payment_information, $order, $payment_needed ) { if ( isset( $payment_intent->payment_method_types ) && count( array_intersect( WC_Stripe_Payment_Methods::VOUCHER_PAYMENT_METHODS, $payment_intent->payment_method_types ) ) !== 0 ) { // For Voucher payment method types (Boleto/Oxxo/Multibanco), redirect the customer to a URL hash formatted #wc-stripe-voucher-{order_id}:{payment_method_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the voucher. return sprintf( @@ -2558,12 +2551,13 @@ private function get_redirect_url( $return_url, $payment_intent, $payment_inform } elseif ( isset( $payment_intent->payment_method_types ) && count( array_intersect( WC_Stripe_Payment_Methods::WALLET_PAYMENT_METHODS, $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}:{payment_intent_type}:{client_secret}:{redirect_url} to confirm the intent which also displays the modal. return sprintf( - '#wc-stripe-wallet-%s:%s:%s:%s:%s', + '#wc-stripe-wallet-%s:%s:%s:%s:%s:%s', $order->get_id(), $payment_information['selected_payment_type'], $payment_intent->object, $payment_intent->client_secret, - rawurlencode( $return_url ) + rawurlencode( $return_url ), + wp_create_nonce( 'wc_stripe_update_order_status_nonce' ) ); } elseif ( isset( $payment_intent->next_action->type ) && in_array( $payment_intent->next_action->type, [ 'redirect_to_url', 'alipay_handle_redirect' ], true ) && ! empty( $payment_intent->next_action->{$payment_intent->next_action->type}->url ) ) { return $payment_intent->next_action->{$payment_intent->next_action->type}->url; diff --git a/readme.txt b/readme.txt index cc378598c..5a6a47797 100644 --- a/readme.txt +++ b/readme.txt @@ -113,5 +113,8 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o = 8.8.1 - xxxx-xx-xx = * Tweak - Disables APMs when using the legacy checkout experience due Stripe deprecation by October 29, 2024. * Fix - Prevent marking orders on-hold with order note "Process order to take payment" when the payment has failed. +* Fix - Prevent subscriptions from being marked as "Pending" when a customer attempts to change their payment method to a declining card. +* Fix - Delay updating the subscription's payment method until after the intent is confirmed when using the new checkout experience. +* Fix - Display a success notice to customers after successfully changing their subscription payment method to a card that required 3DS authentication. [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt).