785 lines
32 KiB
PHP
785 lines
32 KiB
PHP
<?php
|
|
/**
|
|
* Class WC_Payments_Subscriptions_Migrator
|
|
*
|
|
* @package WooCommerce\Payments
|
|
*/
|
|
|
|
use WCPay\Exceptions\API_Exception;
|
|
|
|
require_once __DIR__ . '/class-wc-payments-subscription-migration-log-handler.php';
|
|
|
|
/**
|
|
* Handles migrating WCPay Subscriptions to tokenized subscriptions.
|
|
*
|
|
* This class extends the WCS_Background_Repairer for scheduling and running the individual migration actions.
|
|
*/
|
|
class WC_Payments_Subscriptions_Migrator extends WCS_Background_Repairer {
|
|
|
|
/**
|
|
* Valid subscription statuses to cancel a subscription at Stripe.
|
|
*
|
|
* @var array $active_statuses
|
|
*/
|
|
private $active_statuses = [ 'active', 'past_due', 'trialing', 'paused' ];
|
|
|
|
/**
|
|
* WCPay Subscription meta keys for migrated data.
|
|
*
|
|
* @var array $migrated_meta_keys
|
|
*/
|
|
private $meta_keys_to_migrate = [
|
|
WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
|
WC_Payments_Invoice_Service::ORDER_INVOICE_ID_KEY,
|
|
WC_Payments_Invoice_Service::PENDING_INVOICE_ID_KEY,
|
|
WC_Payments_Subscription_Service::SUBSCRIPTION_DISCOUNT_IDS_META_KEY,
|
|
];
|
|
|
|
/**
|
|
* WC_Payments_API_Client instance.
|
|
*
|
|
* @var WC_Payments_API_Client
|
|
*/
|
|
private $api_client;
|
|
|
|
/**
|
|
* WC_Payments_Token_Service instance.
|
|
*
|
|
* @var WC_Payments_Token_Service
|
|
*/
|
|
private $token_service;
|
|
|
|
/**
|
|
* WC_Payments_Subscription_Migration_Log_Handler instance.
|
|
*
|
|
* @var WC_Payments_Subscription_Migration_Log_Handler
|
|
*/
|
|
protected $logger;
|
|
|
|
/**
|
|
* The Action Scheduler hook used to find and schedule individual migrations of WCPay Subscriptions.
|
|
*
|
|
* @var string
|
|
*/
|
|
public $scheduled_hook = 'wcpay_schedule_subscription_migrations';
|
|
|
|
/**
|
|
* The Action Scheduler hook to migrate a WCPay Subscription.
|
|
*
|
|
* @var string
|
|
*/
|
|
public $migrate_hook = 'wcpay_migrate_subscription';
|
|
|
|
/**
|
|
* The option name used to store a batch identifier for the current migration batch.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $migration_batch_identifier_option = 'wcpay_subscription_migration_batch';
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param WC_Payments_API_Client|null $api_client WC_Payments_API_Client instance.
|
|
* @param WC_Payments_Token_Service|null $token_service WC_Payments_Token_Service instance.
|
|
*/
|
|
public function __construct( $api_client = null, $token_service = null ) {
|
|
$this->api_client = $api_client;
|
|
$this->token_service = $token_service;
|
|
$this->logger = new WC_Payments_Subscription_Migration_Log_Handler();
|
|
|
|
// Don't copy migrated subscription meta keys to related orders.
|
|
add_filter( 'wc_subscriptions_object_data', [ $this, 'exclude_migrated_meta' ], 10, 1 );
|
|
|
|
// Add manual migration tool to WooCommerce > Status > Tools.
|
|
add_filter( 'woocommerce_debug_tools', [ $this, 'add_manual_migration_tool' ] );
|
|
|
|
// Schedule the single migration action with two args. This is needed because the WCS_Background_Repairer parent class only hooks on with one arg.
|
|
add_action( $this->migrate_hook . '_retry', [ $this, 'migrate_wcpay_subscription' ], 10, 2 );
|
|
|
|
$this->init();
|
|
}
|
|
|
|
/**
|
|
* Migrates a WCPay Subscription to a tokenized WooPayments subscription powered by WC Subscriptions
|
|
*
|
|
* Migration process:
|
|
* 1. Validate the request to migrate subscription
|
|
* 2. Fetches the subscription from Stripe
|
|
* 3. Cancels the subscription at Stripe if it is active
|
|
* 4. Update the subscription meta to indicate that it has been migrated
|
|
* 5. Add an order note on the subscription
|
|
*
|
|
* @param int $subscription_id The ID of the subscription to migrate.
|
|
* @param int $attempt The number of times migration has been attempted.
|
|
*/
|
|
public function migrate_wcpay_subscription( $subscription_id, $attempt = 0 ) {
|
|
try {
|
|
add_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ], 10, 2 );
|
|
add_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ], 10, 2 );
|
|
|
|
$this->logger->log( sprintf( 'Migrating subscription #%1$d.%2$s', $subscription_id, ( $attempt > 0 ? ' Attempt: ' . ( (int) $attempt + 1 ) : '' ) ) );
|
|
|
|
$subscription = $this->validate_subscription_to_migrate( $subscription_id );
|
|
$wcpay_subscription = $this->fetch_wcpay_subscription( $subscription );
|
|
|
|
$this->maybe_cancel_wcpay_subscription( $wcpay_subscription );
|
|
|
|
if ( $subscription->has_status( 'active' ) ) {
|
|
$this->update_next_payment_date( $subscription, $wcpay_subscription );
|
|
}
|
|
|
|
// If the subscription is active or on-hold, verify the payment method is valid and set correctly that it continues to renew.
|
|
if ( $subscription->has_status( [ 'active', 'on-hold' ] ) ) {
|
|
$this->verify_subscription_payment_token( $subscription, $wcpay_subscription );
|
|
}
|
|
|
|
$this->update_wcpay_subscription_meta( $subscription );
|
|
|
|
if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() ) {
|
|
$subscription->add_order_note( __( 'This subscription has been successfully migrated to a WooPayments tokenized subscription.', 'woocommerce-payments' ) );
|
|
}
|
|
|
|
$this->logger->log( sprintf( '---- Subscription #%d migration complete.', $subscription_id ) );
|
|
} catch ( \Exception $e ) {
|
|
$this->logger->log( $e->getMessage() );
|
|
|
|
$this->maybe_reschedule_migration( $subscription_id, $attempt, $e );
|
|
}
|
|
|
|
remove_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ] );
|
|
remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] );
|
|
}
|
|
|
|
/**
|
|
* Validates the request to migrate a WCPay Subscription.
|
|
*
|
|
* Only allows migration if:
|
|
* - The WooCommerce Subscription extension is active
|
|
* - Store is not in staging mode or is a duplicate site
|
|
* - The subscription ID is a valid subscription
|
|
* - The subscription has not already been migrated
|
|
*
|
|
* @param int $subscription_id The ID of the subscription to migrate.
|
|
*
|
|
* @throws \Exception Skip the migration if the request is invalid.
|
|
*/
|
|
private function validate_subscription_to_migrate( $subscription_id ) {
|
|
if ( ! class_exists( 'WC_Subscriptions' ) ) {
|
|
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. The WooCommerce Subscriptions extension is not active.', $subscription_id ) );
|
|
}
|
|
|
|
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
|
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Site is in staging mode.', $subscription_id ) );
|
|
}
|
|
|
|
$subscription = wcs_get_subscription( $subscription_id );
|
|
|
|
if ( ! $subscription ) {
|
|
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription not found.', $subscription_id ) );
|
|
}
|
|
|
|
$migrated_wcpay_subscription_id = $subscription->get_meta( '_migrated_wcpay_subscription_id', true );
|
|
|
|
if ( ! empty( $migrated_wcpay_subscription_id ) ) {
|
|
throw new \Exception( sprintf( '---- Skipping migration of subscription #%1$d (%2$s). Subscription has already been migrated.', $subscription_id, $migrated_wcpay_subscription_id ) );
|
|
}
|
|
|
|
return $subscription;
|
|
}
|
|
|
|
/**
|
|
* Fetches the subscription from Stripe and verifies it has a valid ID and status.
|
|
*
|
|
* Returns false if the request returns an unexpected result.
|
|
*
|
|
* @param WC_Subscription $subscription The WC subscription to migrate.
|
|
*
|
|
* @return array
|
|
*
|
|
* @throws \Exception If there's an error fetching the subscription from Stripe.
|
|
*/
|
|
private function fetch_wcpay_subscription( $subscription ) {
|
|
$wcpay_subscription_id = WC_Payments_Subscription_Service::get_wcpay_subscription_id( $subscription );
|
|
|
|
if ( ! $wcpay_subscription_id ) {
|
|
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription is not a WCPay Subscription.', $subscription->get_id() ) );
|
|
}
|
|
|
|
try {
|
|
// Fetch the subscription from Stripe.
|
|
$wcpay_subscription = $this->api_client->get_subscription( $wcpay_subscription_id );
|
|
} catch ( API_Exception $e ) {
|
|
throw new \Exception( sprintf( '---- ERROR: Failed to fetch subscription #%1$d (%2$s) from Stripe. %3$s', $subscription->get_id(), $wcpay_subscription_id, $e->getMessage() ) );
|
|
}
|
|
|
|
if ( empty( $wcpay_subscription['id'] ) || empty( $wcpay_subscription['status'] ) ) {
|
|
throw new \Exception( sprintf( '---- ERROR: Cannot migrate subscription #%1$d (%2$s). Invalid data fetched from Stripe: %3$s', $subscription->get_id(), $wcpay_subscription_id, var_export( $wcpay_subscription, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
|
|
}
|
|
|
|
return $wcpay_subscription;
|
|
}
|
|
|
|
/**
|
|
* Cancels the subscription at Stripe if it is active.
|
|
*
|
|
* This function checks the status on the subscription at Stripe then cancels it if it's a valid status and logs any errors.
|
|
*
|
|
* We skip canceling any subscriptions at Stripe that are:
|
|
* - incomplete: the subscription was created but no payment method was added to the subscription
|
|
* - incomplete_expired: the incomplete subscription expired after 24hrs of no payment method being added.
|
|
* - canceled: the subscription is already canceled
|
|
* - unpaid: this status is not used by subscriptions in WooCommerce Payments
|
|
*
|
|
* @param array $wcpay_subscription The subscription data from Stripe.
|
|
*
|
|
* @throws \Exception If there's an error canceling the subscription at Stripe.
|
|
*/
|
|
private function maybe_cancel_wcpay_subscription( $wcpay_subscription ) {
|
|
// Valid statuses to cancel subscription at Stripe: active, past_due, trialing, paused.
|
|
if ( in_array( $wcpay_subscription['status'], $this->active_statuses, true ) ) {
|
|
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Canceling the subscription.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) );
|
|
|
|
try {
|
|
// Cancel the subscription in Stripe.
|
|
$wcpay_subscription = $this->api_client->cancel_subscription( $wcpay_subscription['id'] );
|
|
} catch ( API_Exception $e ) {
|
|
throw new \Exception( sprintf( '---- ERROR: Failed to cancel the Stripe subscription (%1$s). %2$s', $wcpay_subscription['id'], $e->getMessage() ) );
|
|
}
|
|
|
|
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) successfully canceled.', $wcpay_subscription['id'] ) );
|
|
} else {
|
|
// Statuses that don't need to be canceled: incomplete, incomplete_expired, canceled, unpaid.
|
|
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Skipping canceling the subscription at Stripe.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrates WCPay Subscription related metadata to a new key prefixed with `_migrated` and deletes the old meta.
|
|
*
|
|
* @param WC_Subscription $subscription The subscription with wcpay meta saved.
|
|
*/
|
|
private function update_wcpay_subscription_meta( $subscription ) {
|
|
$updated = false;
|
|
|
|
/**
|
|
* If this subscription is being migrated while scheduling individual actions is on-going, make sure we store meta on the subscription
|
|
* so that it's still returned by the query in @see get_items_to_repair() to not affect the limit and pagination.
|
|
*/
|
|
$migration_start = get_option( $this->migration_batch_identifier_option, 0 );
|
|
|
|
if ( 0 !== $migration_start ) {
|
|
$subscription->update_meta_data( '_wcpay_subscription_migrated_during', $migration_start );
|
|
$updated = true;
|
|
}
|
|
|
|
foreach ( $this->meta_keys_to_migrate as $meta_key ) {
|
|
if ( $subscription->meta_exists( $meta_key ) ) {
|
|
$subscription->update_meta_data( '_migrated' . $meta_key, $subscription->get_meta( $meta_key, true ) );
|
|
$subscription->delete_meta_data( $meta_key );
|
|
|
|
$updated = true;
|
|
}
|
|
}
|
|
|
|
if ( $updated ) {
|
|
$subscription->save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the subscription's next payment date in WooCommerce to ensure a smooth transition to on-site billing.
|
|
*
|
|
* There's a scenario where a WCPay subscription is active but has no pending renewal scheduled action.
|
|
* Once migrated, this results in an active subscription that will remain active forever, without processing a renewal order.
|
|
*
|
|
* To ensure that all migrated subscriptions have a pending scheduled action, we need to reschedule the next payment date by
|
|
* updating the date on the subscription.
|
|
*
|
|
* In priority order the new next payment date will be:
|
|
* - The existing WooCommerce next payment date if it's in the future.
|
|
* - The Stripe subscription's current_period_end if it's in the future.
|
|
* - A newly calculated next payment date using the WC_Subscription::calculate_date() method.
|
|
*
|
|
* @param WC_Subscription $subscription The WC Subscription being migrated.
|
|
* @param array $wcpay_subscription The subscription data from Stripe.
|
|
*/
|
|
private function update_next_payment_date( $subscription, $wcpay_subscription ) {
|
|
try {
|
|
// Just update the existing WC Subscription's next payment date if it's in the future.
|
|
if ( $subscription->get_time( 'next_payment' ) > time() ) {
|
|
$new_next_payment = gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) + 1 );
|
|
|
|
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
|
$this->logger->log( sprintf( '---- Next payment date updated to %1$s to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) );
|
|
|
|
return;
|
|
}
|
|
|
|
// If the subscription was still using WooPayments, use the Stripe subscription's next payment time (current_period_end) if it's in the future.
|
|
if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() && isset( $wcpay_subscription['current_period_end'] ) && absint( $wcpay_subscription['current_period_end'] ) > time() ) {
|
|
$new_next_payment = gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) );
|
|
|
|
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
|
$this->logger->log( sprintf( '---- Next payment date updated to %1$s to match Stripe subscription record and to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) );
|
|
|
|
return;
|
|
}
|
|
|
|
// Lastly calculate the next payment date.
|
|
$new_next_payment = $subscription->calculate_date( 'next_payment' );
|
|
|
|
if ( wcs_date_to_time( $new_next_payment ) > time() ) {
|
|
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
|
$this->logger->log( sprintf( '---- Calculated a new next payment date (%1$s) to ensure subscription #%2$d has a pending scheduled payment in the future.', $new_next_payment, $subscription->get_id() ) );
|
|
|
|
return;
|
|
}
|
|
|
|
// If we got here the next payment date is in the past, the Stripe subscription is missing a "current_period_end" or it's in the past, and calculating a new date also failed. Log an error.
|
|
$this->logger->log(
|
|
sprintf(
|
|
'---- ERROR: Failed to update subscription #%1$d next payment date. Current next payment date (%2$s) is in the past, Stripe "current_period_end" data is invalid (%3$s) and an attempt to calculate a new date also failed (%4$s).',
|
|
$subscription->get_id(),
|
|
gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) ),
|
|
isset( $wcpay_subscription['current_period_end'] ) ? gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) ) : 'no data',
|
|
$new_next_payment
|
|
)
|
|
);
|
|
} catch ( \Exception $e ) {
|
|
$this->logger->log( sprintf( '---- ERROR: Failed to update subscription #%1$d next payment date. %2$s', $subscription->get_id(), $e->getMessage() ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the subscription status from the WCPay subscription data for logging purposes.
|
|
*
|
|
* If a subscription is on-hold in WC we wouldn't have changed the status of the subscription at Stripe, instead, the
|
|
* subscription would remain active and set `pause_collection` behavior to `void` so that the subscription is not charged.
|
|
*
|
|
* The purpose of this function is to handle the `paused_collection` value when mapping the subscription status at Stripe to
|
|
* a status for logging.
|
|
*
|
|
* @param array $wcpay_subscription The subscription data from Stripe.
|
|
*
|
|
* @return string The WCPay subscription status for logging purposes.
|
|
*/
|
|
private function get_wcpay_subscription_status( $wcpay_subscription ) {
|
|
if ( empty( $wcpay_subscription['status'] ) ) {
|
|
return 'unknown';
|
|
}
|
|
|
|
if ( 'active' === $wcpay_subscription['status'] && ! empty( $wcpay_subscription['pause_collection']['behavior'] ) && 'void' === $wcpay_subscription['pause_collection']['behavior'] ) {
|
|
return 'paused';
|
|
}
|
|
|
|
return $wcpay_subscription['status'];
|
|
}
|
|
|
|
/**
|
|
* Verifies the payment token on the subscription matches the default payment method on the WCPay Subscription.
|
|
*
|
|
* This function does two things:
|
|
* 1. If the subscription doesn't have a WooPayments payment token, set it to the default payment method from Stripe Billing.
|
|
* 2. If the subscription has a token, verify the token matches the token on the Stripe Billing subscription
|
|
*
|
|
* @param WC_Subscription $subscription The subscription to verify the payment token on.
|
|
* @param array $wcpay_subscription The subscription data from Stripe.
|
|
*/
|
|
private function verify_subscription_payment_token( $subscription, $wcpay_subscription ) {
|
|
// If the subscription's payment method isn't set to WooPayments, we skip this token step.
|
|
if ( $subscription->get_payment_method() !== WC_Payment_Gateway_WCPay::GATEWAY_ID ) {
|
|
$this->logger->log( sprintf( '---- Skipped verifying the payment token. Subscription #%1$d has "%2$s" as the payment method.', $subscription->get_id(), $subscription->get_payment_method() ) );
|
|
return;
|
|
}
|
|
|
|
if ( empty( $wcpay_subscription['default_payment_method'] ) ) {
|
|
$this->logger->log( sprintf( '---- Could not verify the payment method. Stripe Billing subscription (%1$s) does not have a default payment method.', $wcpay_subscription['id'] ?? 'unknown' ) );
|
|
return;
|
|
}
|
|
|
|
$tokens = $subscription->get_payment_tokens();
|
|
$token_id = end( $tokens );
|
|
$token = ! $token_id ? null : WC_Payment_Tokens::get( $token_id );
|
|
|
|
// If the token matches the default payment method on the Stripe Billing subscription, we're done here.
|
|
if ( $token && $token->get_token() === $wcpay_subscription['default_payment_method'] ) {
|
|
$this->logger->log( sprintf( '---- Payment token on subscription #%1$d matches the payment method on the Stripe Billing subscription (%2$s).', $subscription->get_id(), $wcpay_subscription['id'] ?? 'unknown' ) );
|
|
return;
|
|
}
|
|
|
|
// At this point we know the subscription doesn't have a token or the token doesn't match, add one using the default payment method on the WCPay Subscription.
|
|
$new_token = $this->maybe_create_and_update_payment_token( $subscription, $wcpay_subscription );
|
|
|
|
if ( $new_token ) {
|
|
$this->logger->log( sprintf( '---- Payment token on subscription #%1$d has been updated (from %2$s to %3$s) to match the payment method on the Stripe Billing subscription.', $subscription->get_id(), $token ? $token->get_token() : 'missing', $wcpay_subscription['default_payment_method'] ) );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Locates a payment token or creates one if it doesn't exist, then updates the subscription with the new token.
|
|
*
|
|
* @param WC_Subscription $subscription The subscription to add the payment token to.
|
|
* @param array $wcpay_subscription The subscription data from Stripe.
|
|
*
|
|
* @return WC_Payment_Token|false The new payment token or false if the token couldn't be created.
|
|
*/
|
|
private function maybe_create_and_update_payment_token( $subscription, $wcpay_subscription ) {
|
|
$token = false;
|
|
$user = new WP_User( $subscription->get_user_id() );
|
|
$customer_tokens = WC_Payment_Tokens::get_tokens(
|
|
[
|
|
'user_id' => $user->ID,
|
|
'gateway_id' => WC_Payment_Gateway_WCPay::GATEWAY_ID,
|
|
'limit' => WC_Payment_Gateway_WCPay::USER_FORMATTED_TOKENS_LIMIT,
|
|
]
|
|
);
|
|
|
|
foreach ( $customer_tokens as $customer_token ) {
|
|
if ( $customer_token->get_token() === $wcpay_subscription['default_payment_method'] ) {
|
|
$token = $customer_token;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we didn't find a token linked to the subscription customer, create one.
|
|
if ( ! $token ) {
|
|
try {
|
|
$token = $this->token_service->add_payment_method_to_user( $wcpay_subscription['default_payment_method'], $user );
|
|
$this->logger->log( sprintf( '---- Created a new payment token (%1$s) for subscription #%2$d.', $token->get_token(), $subscription->get_id() ) );
|
|
} catch ( \Exception $e ) {
|
|
$this->logger->log( sprintf( '---- WARNING: Subscription #%1$d is missing a payment token and we failed to create one. Error: %2$s', $subscription->get_id(), $e->getMessage() ) );
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Prevent the WC_Payments_Subscriptions class from attempting to update the Stripe Billing subscription's payment method while we set the token.
|
|
remove_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10 );
|
|
|
|
$subscription->add_payment_token( $token );
|
|
|
|
// Reattach.
|
|
add_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10, 3 );
|
|
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* Prevents migrated WCPay subscription metadata being copied to subscription related orders (renewal/switch/resubscribe).
|
|
*
|
|
* @param array $meta_data The meta data to be copied.
|
|
* @return array The meta data to be copied.
|
|
*/
|
|
public function exclude_migrated_meta( $meta_data ) {
|
|
foreach ( $this->meta_keys_to_migrate as $key ) {
|
|
unset( $meta_data[ '_migrated' . $key ] );
|
|
}
|
|
|
|
return $meta_data;
|
|
}
|
|
|
|
/**
|
|
* Logs any fatal errors that occur while processing a scheduled migrate WCPay Subscription action.
|
|
*
|
|
* @param string $action_id The Action Scheduler action ID.
|
|
* @param array $error The error data.
|
|
*/
|
|
public function handle_unexpected_shutdown( $action_id, $error = null ) {
|
|
$migration_args = $this->get_migration_action_args( $action_id );
|
|
|
|
if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! empty( $error['type'] ) && in_array( $error['type'], [ E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ], true ) ) {
|
|
$this->logger->log( sprintf( '---- ERROR: Unexpected shutdown while migrating subscription #%1$d: %2$s in %3$s on line %4$s.', $migration_args['migrate_subscription'], $error['message'] ?? 'No message', $error['file'] ?? 'no file found', $error['line'] ?? '0' ) );
|
|
}
|
|
|
|
$this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] );
|
|
}
|
|
|
|
/**
|
|
* Handles any unexpected failures that occur while processing a single migration action
|
|
* by logging an error message and rescheduling the action to retry.
|
|
*
|
|
* @param string $action_id The Action Scheduler action ID.
|
|
* @param Exception $exception The exception thrown during action processing.
|
|
*/
|
|
public function handle_unexpected_action_failure( $action_id, $exception ) {
|
|
$migration_args = $this->get_migration_action_args( $action_id );
|
|
|
|
if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) {
|
|
return;
|
|
}
|
|
|
|
$this->logger->log( sprintf( '---- ERROR: Unexpected failure while migrating subscription #%1$d: %2$s', $migration_args['migrate_subscription'], $exception->getMessage() ) );
|
|
$this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] );
|
|
}
|
|
|
|
/**
|
|
* Adds a manual migration tool to WooCommerce > Status > Tools.
|
|
*
|
|
* This tool is only loaded on stores that have:
|
|
* - WC Subscriptions extension activated
|
|
* - Subscriptions with WooPayments feature disabled
|
|
* - Existing WCPay Subscriptions that can be migrated
|
|
*
|
|
* @param array $tools List of WC debug tools.
|
|
*
|
|
* @return array List of WC debug tools.
|
|
*/
|
|
public function add_manual_migration_tool( $tools ) {
|
|
if ( WC_Payments_Features::is_wcpay_subscriptions_enabled() || ! class_exists( 'WC_Subscriptions' ) ) {
|
|
return $tools;
|
|
}
|
|
|
|
// Get number of WCPay Subscriptions that can be migrated.
|
|
$wcpay_subscriptions_count = $this->get_stripe_billing_subscription_count();
|
|
|
|
if ( $wcpay_subscriptions_count < 1 ) {
|
|
return $tools;
|
|
}
|
|
|
|
// Disable the button if a migration is currently in progress.
|
|
$disabled = $this->is_migrating();
|
|
|
|
$tools['migrate_wcpay_subscriptions'] = [
|
|
'name' => __( 'Migrate Stripe Billing subscriptions', 'woocommerce-payments' ),
|
|
'button' => $disabled ? __( 'Migration in progress', 'woocommerce-payments' ) . '…' : __( 'Migrate Subscriptions', 'woocommerce-payments' ),
|
|
'desc' => sprintf(
|
|
// translators: %1$s is a new line character and %2$d is the number of subscriptions.
|
|
__( 'This tool will migrate all Stripe Billing subscriptions to tokenized subscriptions with WooPayments.%1$sNumber of Stripe Billing subscriptions found: %2$d', 'woocommerce-payments' ),
|
|
'<br>',
|
|
$wcpay_subscriptions_count,
|
|
),
|
|
'callback' => [ $this, 'schedule_migrate_wcpay_subscriptions_action' ],
|
|
'disabled' => $disabled,
|
|
'requires_refresh' => true,
|
|
];
|
|
|
|
return $tools;
|
|
}
|
|
|
|
/**
|
|
* Schedules the initial migration action which signals the start of the migration process.
|
|
*/
|
|
public function schedule_migrate_wcpay_subscriptions_action() {
|
|
if ( as_next_scheduled_action( $this->scheduled_hook ) ) {
|
|
return;
|
|
}
|
|
|
|
update_option( $this->migration_batch_identifier_option, time() );
|
|
|
|
$this->logger->log( 'Started scheduling subscription migrations.' );
|
|
$this->schedule_repair();
|
|
}
|
|
|
|
/**
|
|
* Gets the subscription ID and number of attempts from the action args.
|
|
*
|
|
* @param int $action_id The action ID to get data from.
|
|
*
|
|
* @return array
|
|
*/
|
|
private function get_migration_action_args( $action_id ) {
|
|
$action = ActionScheduler_Store::instance()->fetch_action( $action_id );
|
|
|
|
if ( ! $action || ( $this->migrate_hook !== $action->get_hook() && $this->migrate_hook . '_retry' !== $action->get_hook() ) ) {
|
|
return [];
|
|
}
|
|
|
|
$action_args = $action->get_args();
|
|
|
|
if ( ! isset( $action_args['migrate_subscription'] ) ) {
|
|
return [];
|
|
}
|
|
|
|
return array_merge(
|
|
[
|
|
'migrate_subscription' => 0,
|
|
'attempt' => 0,
|
|
],
|
|
$action_args
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reschedules a subscription migration with increasing delays depending on number of attempts.
|
|
*
|
|
* After max retries, an exception is thrown if one was passed.
|
|
*
|
|
* @param int $subscription_id The ID of the subscription to retry.
|
|
* @param int $attempt The number of times migration has been attempted.
|
|
* @param \Exception|null $exception The exception thrown during migration.
|
|
*
|
|
* @throws \Exception If max attempts and exception passed is not null.
|
|
*/
|
|
public function maybe_reschedule_migration( $subscription_id, $attempt = 0, $exception = null ) {
|
|
// Number of seconds to wait before retrying the migration, increasing with each attempt up to 7 attempts (12 hours).
|
|
$retry_schedule = [ 60, 300, 600, 1800, HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, 12 * HOUR_IN_SECONDS ];
|
|
|
|
// If the exception thrown contains "Skipping migration", don't reschedule the migration.
|
|
if ( $exception && false !== strpos( $exception->getMessage(), 'Skipping migration' ) ) {
|
|
return;
|
|
}
|
|
|
|
if ( isset( $retry_schedule[ $attempt ] ) && $attempt < 7 ) {
|
|
$this->logger->log( sprintf( '---- Rescheduling migration of subscription #%1$d.', $subscription_id ) );
|
|
|
|
as_schedule_single_action(
|
|
gmdate( 'U' ) + $retry_schedule[ $attempt ],
|
|
$this->migrate_hook . '_retry',
|
|
[
|
|
'migrate_subscription' => $subscription_id,
|
|
'attempt' => $attempt + 1,
|
|
]
|
|
);
|
|
} else {
|
|
$this->logger->log( sprintf( '---- FAILED: Subscription #%d could not be migrated.', $subscription_id ) );
|
|
|
|
if ( $exception ) {
|
|
// Before throwing the exception, remove the action_scheduler failure hook to prevent the exception being logged again.
|
|
remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] );
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Override WCS_Background_Repairer methods.
|
|
*/
|
|
|
|
/**
|
|
* Initialize class variables and hooks to handle scheduling and running migration hooks in the background.
|
|
*/
|
|
public function init() {
|
|
$this->repair_hook = $this->migrate_hook;
|
|
|
|
parent::init();
|
|
}
|
|
|
|
/**
|
|
* Schedules an individual action to migrate a subscription.
|
|
*
|
|
* Overrides the parent class function to make two changes:
|
|
* 1. Don't schedule an action if one already exists.
|
|
* 2. Schedules the migration to happen in one minute instead of in one hour.
|
|
*
|
|
* @param int $item The ID of the subscription to migrate.
|
|
*/
|
|
public function update_item( $item ) {
|
|
if ( ! as_next_scheduled_action( $this->migrate_hook, [ 'migrate_subscription' => $item ] ) ) {
|
|
as_schedule_single_action( gmdate( 'U' ) + 60, $this->migrate_hook, [ 'migrate_subscription' => $item ] );
|
|
}
|
|
|
|
unset( $this->items_to_repair[ $item ] );
|
|
}
|
|
|
|
/**
|
|
* Migrates an individual subscription.
|
|
*
|
|
* The repair_item() function is called by the parent class when the individual scheduled action is run.
|
|
* This acts as a wrapper for the migrate_wcpay_subscription() function.
|
|
*
|
|
* @param int $item The ID of the subscription to migrate.
|
|
*/
|
|
public function repair_item( $item ) {
|
|
$this->migrate_wcpay_subscription( $item );
|
|
}
|
|
|
|
/**
|
|
* Gets a batch of 100 subscriptions to migrate.
|
|
*
|
|
* Because this function fetches items in batches using limit and paged query args, we need to make sure
|
|
* the paging of this query is consistent regardless of whether some subscriptions have been repaired/migrated in between.
|
|
*
|
|
* To do this, we use the $this->migration_batch_identifier_option value to identify subscriptions previously returned by
|
|
* this function that have been migrated so they will still be considered for paging.
|
|
*
|
|
* @param int $page The page of results to fetch.
|
|
*
|
|
* @return int[] The IDs of the subscriptions to migrate.
|
|
*/
|
|
public function get_items_to_repair( $page ) {
|
|
$items_to_migrate = wcs_get_orders_with_meta_query(
|
|
[
|
|
'return' => 'ids',
|
|
'type' => 'shop_subscription',
|
|
'limit' => 100,
|
|
'status' => 'any',
|
|
'paged' => $page,
|
|
'order' => 'ASC',
|
|
'orderby' => 'ID',
|
|
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
|
'relation' => 'OR',
|
|
[
|
|
'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
|
'compare' => 'EXISTS',
|
|
],
|
|
// We need to include subscriptions which have already been migrated as part of this migration group to make
|
|
// sure correct paging is maintained. As subscriptions are migrated they would migrate the WCPay subscription ID
|
|
// meta key and therefore fall out of this query's scope - messing with the paging of future queries.
|
|
// Subscriptions with the `migrated_during` meta aren't expected to be returned by this query, they are included to pad out the earlier pages.
|
|
[
|
|
'key' => '_wcpay_subscription_migrated_during',
|
|
'value' => get_option( $this->migration_batch_identifier_option, 0 ),
|
|
'compare' => '=',
|
|
],
|
|
],
|
|
]
|
|
);
|
|
|
|
if ( empty( $items_to_migrate ) ) {
|
|
$this->logger->log( 'Finished scheduling subscription migrations.' );
|
|
}
|
|
|
|
return $items_to_migrate;
|
|
}
|
|
|
|
/**
|
|
* Gets the total number of subscriptions to migrate.
|
|
*
|
|
* @return int The total number of subscriptions to migrate.
|
|
*/
|
|
public function get_stripe_billing_subscription_count() {
|
|
$result = wcs_get_orders_with_meta_query(
|
|
[
|
|
'status' => 'any',
|
|
'return' => 'ids',
|
|
'type' => 'shop_subscription',
|
|
'limit' => - 1,
|
|
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
|
[
|
|
'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
|
'compare' => 'EXISTS',
|
|
],
|
|
],
|
|
]
|
|
);
|
|
|
|
return is_countable( $result ) ? count( $result ) : 0;
|
|
}
|
|
|
|
/**
|
|
* Determines if a migration is currently in progress.
|
|
*
|
|
* A migration is considered to be in progress if the initial migration action or an individual subscription
|
|
* action (or retry) is scheduled.
|
|
*
|
|
* @return bool True if a migration is in progress, false otherwise.
|
|
*/
|
|
public function is_migrating() {
|
|
return (bool) as_next_scheduled_action( $this->scheduled_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook . '_retry' );
|
|
}
|
|
|
|
/**
|
|
* Runs any actions that need to handle the completion of the migration.
|
|
*/
|
|
protected function unschedule_background_updates() {
|
|
parent::unschedule_background_updates();
|
|
|
|
delete_option( $this->migration_batch_identifier_option );
|
|
}
|
|
}
|