diff --git a/changelog.txt b/changelog.txt index 3093abd4b..f1016bee0 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,7 @@ = 8.5.0 - 2024-xx-xx = * Tweak - Additional visual improvement for the webhook configuration notice. * Add - Allow changing display order of payment methods in the new checkout experience. +* Add - Update the payment method associated with a subscription to a PaymentMethod when it's using a Stripe Source that was migrated to PaymentMethods. * Fix - Prevent subscriptions using Legacy SEPA from switching to Manual Renewal when disabling the Legacy experience. * Tweak - Add a notice in checkout for Cash App transactions above 2000 USD to inform customers about the decline risk. * Tweak - Improve the display of warning messages related to webhook configuration. @@ -13,8 +14,6 @@ * Fix - Ensure subscriptions purchased with iDEAL or Bancontact are correctly set to SEPA debit prior to processing the intitial payment. * Tweak - Stripe API version updated to support 2024-06-20. * Fix - Ensure SEPA tokens are attached to customers in the legacy checkout experience when the payment method is saved. This addresses subscription recurring payment "off-session" errors with SEPA. -* Tweak - Limit the configure webhooks button to 1 click per minute to prevent multiple webhook creations. -* Fix - Address Klarna currency rules to ensure correct presentment and availability based on merchant and customer locations. = 8.4.0 - 2024-06-13 = * Tweak - Resets the list of payment methods when any Stripe key is updated. diff --git a/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php b/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php index 660ee5d38..a3d65b89e 100644 --- a/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php +++ b/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php @@ -19,15 +19,9 @@ * WooCommerce detects that the stripe_sepa payment gateway as no longer available. * This causes the Subscription to change to Manual renewal, and automatic renewals to fail. * - * This class fixes failing automatic renewals by: - * - Retrieving all the subscriptions that are using the stripe_sepa payment gateway. - * - Iterating over each subscription. - * - Retrieving an Updated (Payment Methods API) token based on the Legacy (Sources API) token associated with the subscription. - * - If none is found, we create a new Updated (Payment Methods API) token based on the Legacy (Sources API) token. - * - If it can't be created, we skip the migration. - * - Associating this replacement token to the subscription. - * - * This class extends the WCS_Background_Repairer for scheduling and running the individual migration actions. + * This class updates the following for the given subscription: + * - The associated gateway ID to the one used for the updated checkout experience `stripe_sepa_debit`, so it doesn't switch to Manual Renewal. + * - The payment method used for renewals to the migrated pm_, if any. */ class WC_Stripe_Subscriptions_Legacy_SEPA_Token_Update { @@ -60,16 +54,30 @@ class WC_Stripe_Subscriptions_Legacy_SEPA_Token_Update { */ public function maybe_update_subscription_legacy_payment_method( $subscription_id ) { $subscription = $this->get_subscription_to_migrate( $subscription_id ); - $source_id = $subscription->get_meta( self::SOURCE_ID_META_KEY ); - $user_id = $subscription->get_user_id(); - - // Try to create an updated SEPA gateway token if none exists. - // We don't need this to update the subscription, but creating one for consistency. - // It could be confusing for a merchant to see the subscription renewing but no saved token in the store. - $this->maybe_create_updated_sepa_token_by_source_id( $source_id, $user_id ); // Update the subscription with the updated SEPA gateway ID. - $this->set_subscription_updated_payment_method( $subscription ); + $this->set_subscription_updated_payment_gateway_id( $subscription ); + + // Update the payment method to the migrated pm_. + $this->maybe_update_subscription_source( $subscription ); + } + + /** + * Attempts to update the payment method for renewals from Sources to PaymentMethods. + * + * @param WC_Subscription $subscription The subscription for which the payment method must be updated. + */ + public function maybe_update_subscription_source( WC_Subscription $subscription ) { + try { + $this->set_subscription_updated_payment_method( $subscription ); + + $order_note = __( 'Stripe Gateway: The payment method used for renewals was updated from Sources to PaymentMethods.', 'woocommerce-gateway-stripe' ); + } catch ( \Exception $e ) { + /* translators: Reason why the subscription payment method wasn't updated */ + $order_note = sprintf( __( 'Stripe Gateway: A Source is used for renewals but could not be updated to PaymentMethods. Reason: %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ); + } + + $subscription->add_order_note( $order_note ); } /** @@ -79,11 +87,10 @@ public function maybe_update_subscription_legacy_payment_method( $subscription_i * - The Legacy experience is disabled * - The WooCommerce Subscription extension is active * - The subscription ID is a valid subscription - * - The payment method associated with the subscription is the legacy SEPA gateway, `stripe_sepa` * * @param int $subscription_id The ID of the subscription to update. * @return WC_Subscription An instance of the subscription to be updated. - * @throws \Exception When the subscription can't or doesn't need to be updated. + * @throws \Exception When the subscription can't be updated. */ private function get_subscription_to_migrate( $subscription_id ) { if ( ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { @@ -100,77 +107,41 @@ private function get_subscription_to_migrate( $subscription_id ) { throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription not found.', $subscription_id ) ); } - if ( WC_Gateway_Stripe_Sepa::ID !== $subscription->get_payment_method() ) { - throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription is not using the legacy SEPA payment method.', $subscription_id ) ); - } - return $subscription; } /** - * Returns an updated token to be used for the subscription, given the source ID. + * Updates the payment method used for renewals to the migrated pm_, if any. * - * If no updated token is found, we create a new one based on the legacy one. + * The subscription is using a source for renewals at this point. + * When the migration runs on the Stripe account, there will be a payment method (pm_) migrated from the source (src_). + * This method updates the subscription to use the migrated payment method (pm_) for renewals, if it exists. * - * @param string $source_id The Source or Payment Method ID associated with the subscription. - * @param integer $user_id The WordPress User ID to whom the subscription belongs. + * @param WC_Subscription $subscription The subscription to update. + * @throws \Exception When the subscription is already using a pm_ or its src_ hasn't been migrated to a pm_. */ - private function maybe_create_updated_sepa_token_by_source_id( string $source_id, int $user_id ) { - - // Retrieve the updated SEPA tokens for the user. - $replacement_token = $this->get_customer_token_by_source_id( $source_id, $user_id, $this->updated_sepa_gateway_id ); - - // If no updated SEPA token was found, create a new one based on the source ID. - if ( ! $replacement_token ) { - $replacement_token = $this->create_updated_sepa_token( $source_id, $user_id ); - } - } - - /** - * Get the token for the user by its source ID and gateway ID. s - * - * @param string $source_id The ID of the source we're looking for. - * @param integer $user_id The ID of the user we're retrieving tokens for. - * @param string $gateway_id The ID of the gateway of the tokens we want to check. - * - * @return WC_Payment_Token|false - */ - private function get_customer_token_by_source_id( string $source_id, int $user_id, string $gateway_id ) { - $customer_tokens = WC_Payment_Tokens::get_customer_tokens( $user_id, $gateway_id ); + private function set_subscription_updated_payment_method( WC_Subscription $subscription ) { + $source_id = $subscription->get_meta( self::SOURCE_ID_META_KEY ); - foreach ( $customer_tokens as $token ) { - if ( $source_id === $token->get_token() ) { - return $token; - } + // Bail out if the subscription is already using a pm_. + if ( 0 === strpos( $source_id, 'src_' ) ) { + throw new \Exception( sprintf( 'The subscription is not using a Stripe Source for renewals.', $subscription->get_id() ) ); } - return false; - } - - /** - * Creates an updated SEPA token given the source ID. - * - * @param string $source_id Source ID from which to create the new token. - * @param integer $user_id - * @return WC_Payment_Token_SEPA|bool The new SEPA token, or false if no legacy token was found. - */ - private function create_updated_sepa_token( string $source_id, int $user_id ) { - $legacy_token = $this->get_customer_token_by_source_id( $source_id, $user_id, WC_Gateway_Stripe_Sepa::ID ); + // Retrieve the source object from the API. + $source_object = WC_Stripe_API::get_payment_method( $source_id ); - // Bail out if we don't have a token from which to create an updated one. - if ( ! $legacy_token ) { - return false; + // Bail out if the src_ hasn't been migrated to pm_ yet. + if ( ! isset( $source_object->metadata->migrated_payment_method ) ) { + throw new \Exception( sprintf( 'The Source has not been migrated to PaymentMethods on the Stripe account.', $subscription->get_id() ) ); } - $token = new WC_Payment_Token_SEPA(); - $token->set_last4( $legacy_token->get_last4() ); - $token->set_payment_method_type( $legacy_token->get_payment_method_type() ); - $token->set_gateway_id( $this->updated_sepa_gateway_id ); - $token->set_token( $source_id ); - $token->set_user_id( $user_id ); - $token->save(); + // Get the payment method ID that was migrated from the source. + $migrated_payment_method_id = $source_object->metadata->migrated_payment_method; - return $token; + // And set it as the payment method for the subscription. + $subscription->update_meta_data( self::SOURCE_ID_META_KEY, $migrated_payment_method_id ); + $subscription->save(); } /** @@ -178,7 +149,12 @@ private function create_updated_sepa_token( string $source_id, int $user_id ) { * * @param WC_Subscription $subscription Subscription for which the payment method must be updated. */ - private function set_subscription_updated_payment_method( WC_Subscription $subscription ) { + private function set_subscription_updated_payment_gateway_id( WC_Subscription $subscription ) { + // The subscription is not using the legacy SEPA gateway ID. + if ( WC_Gateway_Stripe_Sepa::ID !== $subscription->get_payment_method() ) { + throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription is not using the legacy SEPA payment method.', $subscription->get_id() ) ); + } + // Add a meta to the subscription to flag that its token got updated. $subscription->update_meta_data( self::LEGACY_TOKEN_PAYMENT_METHOD_META_KEY, WC_Gateway_Stripe_Sepa::ID ); $subscription->set_payment_method( $this->updated_sepa_gateway_id ); diff --git a/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php b/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php index e4d9f8371..763342a55 100644 --- a/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php +++ b/includes/migrations/class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php @@ -6,7 +6,7 @@ defined( 'ABSPATH' ) || exit; /** - * Handles migrating the tokens of Subscriptions using SEPA's Legacy gateway ID. + * Handles repairing the Subscriptions using SEPA's Legacy payment method. * * This class extends the WCS_Background_Repairer for scheduling and running the individual migration actions. */ @@ -115,10 +115,29 @@ public function maybe_migrate_before_renewal( $subscription_id ) { $subscription = wcs_get_subscription( $subscription_id ); - if ( $subscription && $subscription->get_payment_method() === WC_Gateway_Stripe_Sepa::ID ) { + if ( ! $subscription ) { + return; + } + + // Run the full repair if the subscription is using the Legacy SEPA gateway ID. + if ( $subscription->get_payment_method() === WC_Gateway_Stripe_Sepa::ID ) { $this->repair_item( $subscription_id ); + // Unschedule the repair action as it's no longer needed. as_unschedule_action( $this->repair_hook, [ 'repair_object' => $subscription_id ] ); + + // Returning at this point because the source will be updated by the repair_item method called above. + return; + } + + // It's possible that the Legacy SEPA gateway ID was updated by the repairing above, but that the Stripe account + // hadn't been migrated from src_ to pm_ at the time. + // Thus, we keep checking if the associated payment method is a source in subsequent renewals. + $subscription_source = $subscription->get_meta( '_stripe_source_id' ); + + if ( 0 === strpos( $subscription_source, 'src_' ) ) { + $token_updater = new WC_Stripe_Subscriptions_Legacy_SEPA_Token_Update(); + $token_updater->maybe_update_subscription_source( $subscription ); } } } diff --git a/readme.txt b/readme.txt index 097864378..4c617aa3d 100644 --- a/readme.txt +++ b/readme.txt @@ -131,6 +131,7 @@ If you get stuck, you can ask for help in the Plugin Forum. = 8.5.0 - 2024-xx-xx = * Tweak - Additional visual improvement for the webhook configuration notice. * Add - Allow changing display order of payment methods in the new checkout experience. +* Add - Update the payment method associated with a subscription to a PaymentMethod when it's using a Stripe Source that was migrated to PaymentMethods. * Fix - Prevent subscriptions using Legacy SEPA from switching to Manual Renewal when disabling the Legacy experience. * Tweak - Add a notice in checkout for Cash App transactions above 2000 USD to inform customers about the decline risk. * Tweak - Improve the display of warning messages related to webhook configuration. diff --git a/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php b/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php index e144fe0b8..895c18b54 100644 --- a/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php +++ b/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php @@ -237,70 +237,6 @@ function ( $id ) { $this->updater->repair_item( $subscription_id ); } - public function test_get_updated_sepa_token_by_source_id_creates_an_updated_token() { - $this->upe_helper->enable_upe_feature_flag(); - $this->upe_helper->enable_upe(); - $this->upe_helper->reload_payment_gateways(); - - $stripe_payment_tokens_instance = WC_Stripe_Payment_Tokens::get_instance(); - - // The SEPA token we create below gets deleted by the method we hook in this filter because it's not found in Stripe. - remove_filter( 'woocommerce_get_customer_payment_tokens', [ $stripe_payment_tokens_instance, 'woocommerce_get_customer_payment_tokens' ], 10, 3 ); - - // Retrieve the actual subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) { - return new WC_Subscription( $id ); - } - ); - - $ids_to_migrate = $this->get_subs_ids_to_migrate(); - $subscription_id = $ids_to_migrate[0]; - $subscription = new WC_Subscription( $subscription_id ); - $customer_id = $subscription->get_user_id(); - - // Create the legacy token associated with the subscription. - $original_source_id = $subscription->get_meta( self::SOURCE_ID_META_KEY ); - $original_token = WC_Helper_Token::create_sepa_token( $original_source_id, $customer_id, $this->legacy_sepa_gateway_id ); - - // Confirm the user doesn't have any updated tokens. - $customer_updated_tokens = WC_Payment_Tokens::get_customer_tokens( $customer_id, $this->updated_sepa_gateway_id ); - $this->assertEmpty( $customer_updated_tokens ); - - $this->logger_mock - ->expects( $this->at( 0 ) ) - ->method( 'add' ) - ->with( - $this->equalTo( 'woocommerce-gateway-stripe-subscriptions-legacy-sepa-tokens-repairs' ), - $this->equalTo( sprintf( 'Migrating subscription #%1$d.', $subscription_id ) ) - ); - - $this->logger_mock - ->expects( $this->at( 1 ) ) - ->method( 'add' ) - ->with( - $this->equalTo( 'woocommerce-gateway-stripe-subscriptions-legacy-sepa-tokens-repairs' ), - $this->equalTo( sprintf( 'Successful migration of subscription #%1$d.', $subscription_id ) ) - ); - - $this->updater->repair_item( $subscription_id ); - - $subscription = new WC_Subscription( $subscription_id ); - - // Confirm the user has an updated token. - $customer_updated_tokens = WC_Payment_Tokens::get_customer_tokens( $customer_id, $this->updated_sepa_gateway_id ); - $this->assertEquals( 1, count( $customer_updated_tokens ) ); - - // Confirm the subscription's payment method was updated. - $this->assertEquals( $this->updated_sepa_gateway_id, $subscription->get_payment_method() ); - - // Confirm the subscription's source ID was updated to use the default token. - $this->assertEquals( $original_source_id, $subscription->get_meta( self::SOURCE_ID_META_KEY ) ); - - // Confirm the flag for the migration was set. - $this->assertEquals( $this->legacy_sepa_gateway_id, $subscription->get_meta( '_migrated_sepa_payment_method' ) ); - } - public function test_get_updated_sepa_token_by_source_id_returns_the_updated_token() { $this->upe_helper->enable_upe_feature_flag(); $this->upe_helper->enable_upe(); @@ -330,10 +266,6 @@ function ( $id ) { // Create the updated token we expect the subscription to be updated with. $updated_token = WC_Helper_Token::create_sepa_token( $original_source_id, $customer_id, $this->updated_sepa_gateway_id ); - // Create default updated token we don't expect the subscription to use. - $default_token = WC_Helper_Token::create_sepa_token( 'src_999', $customer_id, $this->updated_sepa_gateway_id ); - WC_Payment_Tokens::set_users_default( $customer_id, $default_token->get_id() ); - $this->logger_mock ->expects( $this->at( 0 ) ) ->method( 'add' )