Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the subscriptions sources to payment methods if they were migrated #3249

Merged
merged 11 commits into from
Jul 8, 2024
Merged
3 changes: 1 addition & 2 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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 );
}

/**
Expand All @@ -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() ) {
Expand All @@ -100,85 +107,54 @@ 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();
}

/**
* Sets the updated SEPA gateway ID for the subscription.
*
* @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 );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 );
a-danae marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' )
Expand Down
Loading