Skip to content

Commit

Permalink
Introduce separate flow to handle subscription change payment method …
Browse files Browse the repository at this point in the history
…requests for deferred intents (#3539)

* Introduce separate change payment method flow for deferred intents

* Tell subscriptions to not update the payment method until after we've created/confirmed the deferred intent

* Remove the current change payment method handling inside the process_payment function

* Change some UPE methods to protected/public to be used inside the subs trait and intent controller

* Update confirm_change_payment_from_setup_intent_ajax() to support new change payment flow for 3DS cards

* Add changelog entries

* Fix small typos in comments

* Send the confirm_change_payment ajax request to process subscription payment method changes after completing CashApp

* Revert change to return url

---------

Co-authored-by: James Allan <[email protected]>
  • Loading branch information
mattallan and james-allan committed Oct 28, 2024
1 parent 3a2931b commit ff7a1f5
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 27 deletions.
3 changes: 3 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions client/classic/upe/payment-processing.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,14 +405,15 @@ 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 ) {
blockUI( jQueryForm );
}

const partials = window.location.href.match(
/#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+)$/
/#wc-stripe-wallet-(.+):(.+):(.+):(.+):(.+):(.+)$/
);

if ( ! partials ) {
Expand Down Expand Up @@ -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 );
Expand Down
19 changes: 17 additions & 2 deletions includes/class-wc-stripe-intent-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}

Expand All @@ -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(),
Expand Down
119 changes: 119 additions & 0 deletions includes/compat/trait-wc-stripe-subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
40 changes: 17 additions & 23 deletions includes/payment-methods/class-wc-stripe-upe-payment-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 ) ) {
Expand Down Expand Up @@ -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() );
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).

0 comments on commit ff7a1f5

Please sign in to comment.