diff --git a/adminpages/paymentsettings.php b/adminpages/paymentsettings.php index 41a35b29f..2687a5e6f 100644 --- a/adminpages/paymentsettings.php +++ b/adminpages/paymentsettings.php @@ -95,6 +95,17 @@ $pmpro_gateways = pmpro_gateways(); $deprecated_gateways = pmpro_get_deprecated_gateways(); + // Show a confirmation after deprecated gateway data is removed. + $deprecated_gateway_removed_message = ''; + if ( ! empty( $_REQUEST['deprecated_gateway_removed'] ) ) { + $removed_gateway = sanitize_key( wp_unslash( $_REQUEST['deprecated_gateway_removed'] ) ); + $deprecated_gateway_removed_message = sprintf( + // translators: %s is the gateway name. + __( 'The %s gateway and its stored data have been removed from this site.', 'paid-memberships-pro' ), + pmpro_get_gateway_nicename( $removed_gateway ) + ); + } + require_once(dirname(__FILE__) . "/admin_header.php"); ?> @@ -106,6 +117,11 @@ // Show the table of gateways and global settings. ?>
+ +Hi {{ display_name }},
+ +We\'ve updated the way {{ sitename }} processes payments, so we can no longer renew your {{ membership_level_name }} membership automatically.
+ +Your membership stays active until {{ expiration_date }}.
+ +To keep your membership, please check out again before {{ expiration_date }} so you don\'t lose access. Note that checking out again before that date may replace any remaining time on your current membership.
+ +', 'paid-memberships-pro' ) ); + } + + /** + * Get the email template variables for the email paired with a description. + * + * @since TBD + * + * @return array The email template variables for the email. + */ + public static function get_email_template_variables_with_description() { + return array( + '{{ display_name }}' => esc_html__( 'The display name of the user.', 'paid-memberships-pro' ), + '{{ user_login }}' => esc_html__( 'The username of the user.', 'paid-memberships-pro' ), + '{{ user_email }}' => esc_html__( 'The email address of the user.', 'paid-memberships-pro' ), + '{{ membership_id }}' => esc_html__( 'The ID of the membership level.', 'paid-memberships-pro' ), + '{{ membership_level_name }}' => esc_html__( 'The name of the membership level.', 'paid-memberships-pro' ), + '{{ expiration_date }}' => esc_html__( 'The date when the member\'s current access expires.', 'paid-memberships-pro' ), + '{{ checkout_url }}' => esc_html__( 'The checkout URL for the member\'s membership level.', 'paid-memberships-pro' ), + ); + } + + /** + * Get the email address to send the email to. + * + * @since TBD + * + * @return string The email address. + */ + public function get_recipient_email() { + return $this->user->user_email; + } + + /** + * Get the name of the email recipient. + * + * @since TBD + * + * @return string The name. + */ + public function get_recipient_name() { + return $this->user->display_name; + } + + /** + * Get the email template variables for the email. + * + * @since TBD + * + * @return array The email template variables. + */ + public function get_email_template_variables() { + $user = $this->user; + $level = pmpro_getSpecificMembershipLevelForUser( $user->ID, $this->membership_level_id ); + if ( empty( $level ) ) { + $level = pmpro_getLevel( $this->membership_level_id ); + } + + $expiration_date = date_i18n( get_option( 'date_format' ), $this->expiration_timestamp ); + + return array( + 'name' => $user->display_name, + 'display_name' => $user->display_name, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'membership_id' => ! empty( $level->id ) ? $level->id : $this->membership_level_id, + 'membership_level_name' => ! empty( $level->name ) ? $level->name : '', + 'expiration_date' => $expiration_date, + 'checkout_url' => pmpro_url( 'checkout', 'pmpro_level=' . (int) $this->membership_level_id ), + ); + } + + /** + * Returns the arguments to send the test email from the abstract class. + * + * @since TBD + * + * @return array The arguments to send the test email from the abstract class. + */ + public static function get_test_email_constructor_args() { + global $current_user; + + $levels = pmpro_getAllLevels( true ); + $level = current( $levels ); + $level_id = empty( $level ) ? 1 : (int) $level->id; + + return array( $current_user, $level_id, strtotime( '+1 month' ) ); + } +} + +/** + * Register the email template. + * + * @since TBD + * + * @param array $email_templates The email templates. + * @return array The modified email templates array. + */ +function pmpro_deprecated_gateway_register_checkout_required_email_template( $email_templates ) { + if ( function_exists( 'pmpro_has_undeprecated_gateways' ) && pmpro_has_undeprecated_gateways() ) { + $email_templates['deprecated_gateway_checkout_required'] = 'PMPro_Email_Template_Deprecated_Gateway_Checkout_Required'; + } + + return $email_templates; +} +add_filter( 'pmpro_email_templates', 'pmpro_deprecated_gateway_register_checkout_required_email_template' ); diff --git a/classes/email-templates/class-pmpro-email-template-deprecated-gateway-stripe-migration.php b/classes/email-templates/class-pmpro-email-template-deprecated-gateway-stripe-migration.php new file mode 100644 index 000000000..9a755a33f --- /dev/null +++ b/classes/email-templates/class-pmpro-email-template-deprecated-gateway-stripe-migration.php @@ -0,0 +1,219 @@ +subscription = $subscription; + $this->user = get_userdata( $subscription->get_user_id() ); + } + + /** + * Get the email template slug. + * + * @since TBD + * + * @return string The email template slug. + */ + public static function get_template_slug() { + return 'deprecated_gateway_stripe_migration'; + } + + /** + * Get the "nice name" of the email template. + * + * @since TBD + * + * @return string The "nice name" of the email template. + */ + public static function get_template_name() { + return esc_html__( 'Deprecated Gateway Stripe Migration', 'paid-memberships-pro' ); + } + + /** + * Get "help text" to display to the admin when editing the email template. + * + * @since TBD + * + * @return string The help text. + */ + public static function get_template_description() { + return esc_html__( 'This email is sent when an administrator migrates a deprecated gateway subscription to Stripe. It asks the member to add billing information to the new Stripe subscription before its next payment date. It is also sent in place of the upcoming payment reminder while the subscription has no payment method on file. Members who do not add a payment method by that date will have their membership cancelled.', 'paid-memberships-pro' ); + } + + /** + * Get the default subject for the email. + * + * @since TBD + * + * @return string The default subject. + */ + public static function get_default_subject() { + return esc_html__( 'Action required: update your billing information at {{ sitename }}', 'paid-memberships-pro' ); + } + + /** + * Get the default body content for the email. + * + * @since TBD + * + * @return string The default body content. + */ + public static function get_default_body() { + return wp_kses_post( __( 'Hi {{ display_name }},
+ +We\'ve updated the way {{ sitename }} processes payments, so we can no longer use the payment method we had on file for your {{ membership_level_name }} membership.
+ +Your membership is still active, and your next payment is scheduled for {{ next_payment_date }}.
+ +To keep your membership active, please add your payment information before {{ next_payment_date }}. If we don\'t have a payment method on file by then, your membership will be cancelled.
+ +', 'paid-memberships-pro' ) ); + } + + /** + * Get the email template variables for the email paired with a description. + * + * @since TBD + * + * @return array The email template variables for the email. + */ + public static function get_email_template_variables_with_description() { + return array( + '{{ display_name }}' => esc_html__( 'The display name of the user.', 'paid-memberships-pro' ), + '{{ user_login }}' => esc_html__( 'The username of the user.', 'paid-memberships-pro' ), + '{{ user_email }}' => esc_html__( 'The email address of the user.', 'paid-memberships-pro' ), + '{{ membership_id }}' => esc_html__( 'The ID of the membership level.', 'paid-memberships-pro' ), + '{{ membership_level_name }}' => esc_html__( 'The name of the membership level.', 'paid-memberships-pro' ), + '{{ next_payment_date }}' => esc_html__( 'The next payment date for the new Stripe subscription.', 'paid-memberships-pro' ), + '{{ billing_update_url }}' => esc_html__( 'The URL where the member can update billing information for the new Stripe subscription.', 'paid-memberships-pro' ), + ); + } + + /** + * Get the email address to send the email to. + * + * @since TBD + * + * @return string The email address. + */ + public function get_recipient_email() { + // The user may have been deleted since the subscription was created. + return empty( $this->user->user_email ) ? '' : $this->user->user_email; + } + + /** + * Get the name of the email recipient. + * + * @since TBD + * + * @return string The name. + */ + public function get_recipient_name() { + // The user may have been deleted since the subscription was created. + return empty( $this->user->display_name ) ? '' : $this->user->display_name; + } + + /** + * Get the email template variables for the email. + * + * @since TBD + * + * @return array The email template variables. + */ + public function get_email_template_variables() { + $user = $this->user; + $level = pmpro_getSpecificMembershipLevelForUser( $user->ID, $this->subscription->get_membership_level_id() ); + if ( empty( $level ) ) { + $level = pmpro_getLevel( $this->subscription->get_membership_level_id() ); + } + + return array( + 'name' => $user->display_name, + 'display_name' => $user->display_name, + 'user_login' => $user->user_login, + 'user_email' => $user->user_email, + 'membership_id' => ! empty( $level->id ) ? $level->id : $this->subscription->get_membership_level_id(), + 'membership_level_name' => ! empty( $level->name ) ? $level->name : '', + 'next_payment_date' => $this->subscription->get_next_payment_date( get_option( 'date_format' ) ), + 'billing_update_url' => pmpro_url( 'billing', 'pmpro_subscription_id=' . (int) $this->subscription->get_id(), 'https' ), + ); + } + + /** + * Returns the arguments to send the test email from the abstract class. + * + * @since TBD + * + * @return array The arguments to send the test email from the abstract class. + */ + public static function get_test_email_constructor_args() { + global $current_user; + + $levels = pmpro_getAllLevels( true ); + $level = current( $levels ); + if ( empty( $level ) ) { + $level = (object) array( + 'id' => 1, + 'name' => __( 'Membership Level', 'paid-memberships-pro' ), + ); + } + + $subscription = new PMPro_Subscription( + array( + 'id' => 0, + 'user_id' => $current_user->ID, + 'membership_level_id' => $level->id, + 'gateway' => 'stripe', + 'gateway_environment' => get_option( 'pmpro_gateway_environment', 'sandbox' ), + 'subscription_transaction_id' => 'TEST', + 'status' => 'active', + 'next_payment_date' => gmdate( 'Y-m-d H:i:s', strtotime( '+1 month' ) ), + ) + ); + + return array( $subscription ); + } +} + +/** + * Register the email template. + * + * @since TBD + * + * @param array $email_templates The email templates. + * @return array The modified email templates array. + */ +function pmpro_deprecated_gateway_register_stripe_migration_email_template( $email_templates ) { + // Keep this template registered after gateway cleanup while migrated + // subscriptions are still waiting for a payment method, since it is + // also sent in place of their recurring payment reminders. + if ( + ( function_exists( 'pmpro_has_undeprecated_gateways' ) && pmpro_has_undeprecated_gateways() ) || + ( function_exists( 'pmpro_deprecated_gateway_get_needs_payment_method_count' ) && pmpro_deprecated_gateway_get_needs_payment_method_count() > 0 ) + ) { + $email_templates['deprecated_gateway_stripe_migration'] = 'PMPro_Email_Template_Deprecated_Gateway_Stripe_Migration'; + } + + return $email_templates; +} +add_filter( 'pmpro_email_templates', 'pmpro_deprecated_gateway_register_stripe_migration_email_template' ); diff --git a/classes/gateways/class.pmprogateway_stripe.php b/classes/gateways/class.pmprogateway_stripe.php index 1b754d0cf..2b7143400 100644 --- a/classes/gateways/class.pmprogateway_stripe.php +++ b/classes/gateways/class.pmprogateway_stripe.php @@ -3317,6 +3317,192 @@ function pmpro_user_register_stripe_customerid( $user_id ) { return $customer; } + /** + * Create a Stripe subscription used to migrate a deprecated gateway subscription. + * + * This intentionally creates a trialing subscription without a payment method. The + * member is then sent through the billing update flow to attach a payment method + * before the trial ends. + * + * @since TBD + * + * @param PMPro_Subscription $old_subscription The subscription being migrated. + * @param array $args { + * Migration arguments. + * + * @type int $trial_end Unix timestamp when the Stripe subscription should start billing. + * @type int $attempt Creation attempt number. The workflow increments this after a + * previously created placeholder dies so the idempotency key + * changes; reusing the old key within Stripe's 24-hour replay + * window would return the original (now-cancelled) subscription. + * } + * @return Stripe_Subscription|WP_Error + */ + public function create_deprecated_gateway_migration_subscription( $old_subscription, $args = array() ) { + if ( ! is_a( $old_subscription, 'PMPro_Subscription' ) ) { + return new WP_Error( 'pmpro_stripe_migration_invalid_subscription', __( 'Invalid subscription.', 'paid-memberships-pro' ) ); + } + + $args = wp_parse_args( + $args, + array( + 'trial_end' => 0, + 'attempt' => 0, + ) + ); + + $trial_end = (int) $args['trial_end']; + if ( $trial_end <= time() ) { + return new WP_Error( 'pmpro_stripe_migration_invalid_trial_end', __( 'The next billing date must be in the future to create a Stripe migration subscription.', 'paid-memberships-pro' ) ); + } + + $user = get_userdata( $old_subscription->get_user_id() ); + if ( empty( $user ) ) { + return new WP_Error( 'pmpro_stripe_migration_missing_user', __( 'Could not find the subscription user.', 'paid-memberships-pro' ) ); + } + + $level = new PMPro_Membership_Level( $old_subscription->get_membership_level_id() ); + if ( empty( $level->ID ) ) { + return new WP_Error( 'pmpro_stripe_migration_missing_level', __( 'Could not find the subscription membership level.', 'paid-memberships-pro' ) ); + } + + $customer = $this->get_customer_for_user( $old_subscription->get_user_id() ); + if ( empty( $customer ) ) { + $order = new MemberOrder(); + $order->user_id = $old_subscription->get_user_id(); + $order->membership_id = $old_subscription->get_membership_level_id(); + $customer = $this->update_customer_at_checkout( $order ); + } + if ( empty( $customer ) || empty( $customer->id ) ) { + return new WP_Error( 'pmpro_stripe_migration_missing_customer', __( 'Could not create or retrieve the Stripe customer.', 'paid-memberships-pro' ) ); + } + + $product_id = $this->get_product_id_for_level( $level ); + if ( empty( $product_id ) ) { + return new WP_Error( 'pmpro_stripe_migration_missing_product', __( 'Cannot find product for membership level.', 'paid-memberships-pro' ) ); + } + + $billing_amount = $old_subscription->get_billing_amount(); + if ( '' === $billing_amount || null === $billing_amount ) { + $billing_amount = $level->billing_amount; + } + + $price = $this->get_price_for_product( $product_id, $billing_amount, $old_subscription->get_cycle_period(), $old_subscription->get_cycle_number() ); + if ( is_string( $price ) ) { + return new WP_Error( 'pmpro_stripe_migration_missing_price', $price ); + } + + $subscription_params = array( + 'customer' => $customer->id, + 'items' => array( + array( + 'price' => $price->id, + ), + ), + 'trial_end' => $trial_end, + 'trial_settings' => array( + 'end_behavior' => array( + 'missing_payment_method' => 'cancel', + ), + ), + 'description' => sprintf( + // translators: %d: PMPro subscription ID. + __( 'PMPro deprecated gateway migration for subscription #%d', 'paid-memberships-pro' ), + $old_subscription->get_id() + ), + 'metadata' => array( + 'pmpro_user_id' => (string) $old_subscription->get_user_id(), + 'pmpro_membership_level_id' => (string) $old_subscription->get_membership_level_id(), + 'pmpro_old_subscription_id' => (string) $old_subscription->get_id(), + 'pmpro_old_gateway' => (string) $old_subscription->get_gateway(), + 'pmpro_migration_type' => 'deprecated_gateway', + ), + ); + + $application_fee_percentage = $this->get_application_fee_percentage(); + if ( ! empty( $application_fee_percentage ) ) { + $subscription_params['application_fee_percent'] = $application_fee_percentage; + } + + try { + return Stripe_Subscription::create( + $subscription_params, + array( + 'idempotency_key' => 'pmpro-deprecated-gateway-' . $old_subscription->get_gateway_environment() . '-' . $old_subscription->get_id() . '-' . (int) $args['attempt'], + ) + ); + } catch ( Stripe\Error\Base $e ) { + return new WP_Error( 'pmpro_stripe_migration_subscription_error', $e->getMessage() ); + } catch ( \Throwable $e ) { + return new WP_Error( 'pmpro_stripe_migration_subscription_error', $e->getMessage() ); + } catch ( \Exception $e ) { + return new WP_Error( 'pmpro_stripe_migration_subscription_error', $e->getMessage() ); + } + } + + /** + * Check whether Stripe credentials are set for the current gateway environment. + * + * Unlike has_connect_credentials(), this also covers sites using legacy + * API keys. + * + * @since TBD + * + * @return bool + */ + public function has_credentials() { + return ! empty( $this->get_secretkey() ); + } + + /** + * Check whether a Stripe subscription has a payment method to charge. + * + * Checks the subscription's default payment method and source, then falls + * back to the customer's defaults, which is also what Stripe falls back to + * when invoicing. Used to verify migrated subscriptions that were created + * without a payment method. + * + * @since TBD + * + * @param PMPro_Subscription $subscription The subscription to check. + * @return bool|WP_Error Whether a payment method is attached, or WP_Error on API failure. + */ + public function subscription_has_payment_method( $subscription ) { + if ( ! is_a( $subscription, 'PMPro_Subscription' ) ) { + return new WP_Error( 'pmpro_stripe_invalid_subscription', __( 'Invalid subscription.', 'paid-memberships-pro' ) ); + } + + if ( empty( $this->get_secretkey() ) ) { + return new WP_Error( 'pmpro_stripe_no_credentials', __( 'Stripe login credentials are not set.', 'paid-memberships-pro' ) ); + } + + try { + $stripe_subscription = Stripe_Subscription::retrieve( + array( + 'id' => $subscription->get_subscription_transaction_id(), + 'expand' => array( 'customer' ), + ) + ); + } catch ( Stripe\Error\Base $e ) { + return new WP_Error( 'pmpro_stripe_subscription_error', $e->getMessage() ); + } catch ( \Throwable $e ) { + return new WP_Error( 'pmpro_stripe_subscription_error', $e->getMessage() ); + } catch ( \Exception $e ) { + return new WP_Error( 'pmpro_stripe_subscription_error', $e->getMessage() ); + } + + if ( ! empty( $stripe_subscription->default_payment_method ) || ! empty( $stripe_subscription->default_source ) ) { + return true; + } + + $customer = $stripe_subscription->customer; + if ( ! empty( $customer ) && empty( $customer->deleted ) && ( ! empty( $customer->invoice_settings->default_payment_method ) || ! empty( $customer->default_source ) ) ) { + return true; + } + + return false; + } + /** * Convert a price to a positive integer in cents (or 0 for a free price) * representing how much to charge. This is how Stripe wants us to send price amounts. diff --git a/includes/deprecated-gateways.php b/includes/deprecated-gateways.php new file mode 100644 index 000000000..ab4d2bc15 --- /dev/null +++ b/includes/deprecated-gateways.php @@ -0,0 +1,2396 @@ + 0, + 'sandbox' => 0, + ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT gateway_environment, COUNT(*) as count + FROM {$wpdb->pmpro_subscriptions} + WHERE gateway = %s AND status = 'active' + GROUP BY gateway_environment", + $gateway + ) + ); + foreach ( $rows as $row ) { + $counts[ pmpro_deprecated_gateway_normalize_environment( $row->gateway_environment ) ] += (int) $row->count; + } + + return $counts; +} + +/** + * Get active subscription IDs for a gateway/environment after a given ID. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. 'sandbox' matches every + * non-live environment value, mirroring how + * pmpro_deprecated_gateway_get_subscription_counts() buckets them. + * @param int $last_subscription_id Last subscription ID already queried. + * @param int $limit Number of subscription IDs to return. + * @return int[] + */ +function pmpro_deprecated_gateway_get_active_subscription_ids( $gateway, $environment, $last_subscription_id = 0, $limit = 10 ) { + global $wpdb; + + $environment_condition = 'live' === $environment ? "gateway_environment = 'live'" : "gateway_environment != 'live'"; + + $subscription_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT id + FROM {$wpdb->pmpro_subscriptions} + WHERE gateway = %s + AND {$environment_condition} + AND status = 'active' + AND id > %d + ORDER BY id ASC + LIMIT %d", + $gateway, + (int) $last_subscription_id, + (int) $limit + ) + ); + + return array_map( 'intval', $subscription_ids ); +} + +/** + * Get the Action Scheduler group for a gateway/environment. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @return string + */ +function pmpro_deprecated_gateway_get_action_group( $gateway, $environment ) { + return 'pmpro_deprecated_gateway_' . sanitize_key( $gateway ) . '_' . sanitize_key( $environment ); +} + +/** + * Whether pending or running batch actions exist for a gateway/environment. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @return bool + */ +function pmpro_deprecated_gateway_has_scheduled_actions( $gateway, $environment ) { + if ( ! function_exists( 'as_has_scheduled_action' ) ) { + return false; + } + + return as_has_scheduled_action( 'pmpro_deprecated_gateway_process_batch', null, pmpro_deprecated_gateway_get_action_group( $gateway, $environment ) ); +} + +/** + * Get the saved workflow state for a gateway/environment. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @return array Empty array if no workflow has run. + */ +function pmpro_deprecated_gateway_get_state( $gateway, $environment ) { + $state = get_option( 'pmpro_deprecated_gateway_state_' . sanitize_key( $gateway ) . '_' . sanitize_key( $environment ) ); + return is_array( $state ) ? $state : array(); +} + +/** + * Merge changes into the saved workflow state for a gateway/environment. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @param array $changes State keys to update. + * @return array The updated state. + */ +function pmpro_deprecated_gateway_update_state( $gateway, $environment, $changes ) { + $state = array_merge( pmpro_deprecated_gateway_get_state( $gateway, $environment ), $changes, array( 'updated_at' => time() ) ); + update_option( 'pmpro_deprecated_gateway_state_' . sanitize_key( $gateway ) . '_' . sanitize_key( $environment ), $state, false ); + return $state; +} + +/** + * Record the result of processing one subscription in the workflow state and log. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @param string $outcome One of 'complete', 'skipped', 'needs_review'. + * @param string $message Log message. + */ +function pmpro_deprecated_gateway_record_result( $gateway, $environment, $outcome, $message ) { + if ( ! in_array( $outcome, array( 'complete', 'skipped', 'needs_review' ), true ) ) { + $outcome = 'needs_review'; + } + + // Only write the counters being incremented. Writing the full state back + // would revert a concurrent status change (e.g. an admin stopping the + // workflow) to the stale snapshot read above. + $state = pmpro_deprecated_gateway_get_state( $gateway, $environment ); + $changes = array( + 'processed' => empty( $state['processed'] ) ? 1 : $state['processed'] + 1, + $outcome => empty( $state[ $outcome ] ) ? 1 : $state[ $outcome ] + 1, + ); + + pmpro_deprecated_gateway_update_state( $gateway, $environment, $changes ); + pmpro_deprecated_gateway_log( '[' . $outcome . '] ' . $message ); +} + +/** + * Schedule a deprecated gateway workflow for the current environment. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $strategy Strategy slug: 'stripe' or 'expiration'. + * @param bool $send_email Whether to email members. + * @param bool $expire_past_due Expire the membership and cancel at the gateway for subscriptions + * whose next payment date is missing or in the past (instead of skipping them). + * @param bool $dry_run Preview the workflow without making changes. Outcomes are + * logged, but nothing is created, cancelled, emailed, or saved. + * @return true|WP_Error + */ +function pmpro_deprecated_gateway_schedule( $gateway, $strategy, $send_email = true, $expire_past_due = false, $dry_run = false ) { + // Normalize the environment so the state option key always matches what the panel reads. + $environment = pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ); + $gateway = sanitize_key( $gateway ); + $strategy = sanitize_key( $strategy ); + $send_email = ! empty( $send_email ); + $expire_past_due = ! empty( $expire_past_due ); + $dry_run = ! empty( $dry_run ); + + if ( ! in_array( $gateway, pmpro_get_deprecated_gateways(), true ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_not_deprecated', __( 'This workflow is only available for deprecated gateways.', 'paid-memberships-pro' ) ); + } + + if ( ! in_array( $strategy, array( 'stripe', 'expiration' ), true ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_invalid_strategy', __( 'Invalid migration type.', 'paid-memberships-pro' ) ); + } + + if ( pmpro_is_paused() ) { + return new WP_Error( 'pmpro_deprecated_gateway_paused', __( 'Paid Memberships Pro services are paused because this looks like a staging or development copy of your site. Resume services before running this workflow.', 'paid-memberships-pro' ) ); + } + + if ( $gateway === get_option( 'pmpro_gateway' ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_no_replacement', __( 'A different gateway must be active before this workflow can start.', 'paid-memberships-pro' ) ); + } + + if ( 'stripe' === $strategy && 'stripe' !== get_option( 'pmpro_gateway' ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_stripe_unavailable', __( 'Stripe must be the active payment gateway before subscriptions can be migrated to Stripe.', 'paid-memberships-pro' ) ); + } + + // Refuse to start a Stripe migration without credentials: every subscription + // would be flagged needs_review. + if ( 'stripe' === $strategy ) { + $stripe_blockers = pmpro_deprecated_gateway_get_stripe_migration_blockers(); + if ( ! empty( $stripe_blockers ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_stripe_not_ready', $stripe_blockers[0] ); + } + } + + if ( ! function_exists( 'as_enqueue_async_action' ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_no_action_scheduler', __( 'Action Scheduler is not available, so this workflow cannot be scheduled.', 'paid-memberships-pro' ) ); + } + + $counts = pmpro_deprecated_gateway_get_subscription_counts( $gateway ); + $total = 'live' === $environment ? $counts['live'] : $counts['sandbox']; + if ( empty( $total ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_no_subscriptions', __( 'No active subscriptions were found for this gateway in the current environment.', 'paid-memberships-pro' ) ); + } + + // The state check closes the gap between a concurrent request marking the + // workflow as running and its first batch action being enqueued. + $state = pmpro_deprecated_gateway_get_state( $gateway, $environment ); + if ( + pmpro_deprecated_gateway_has_scheduled_actions( $gateway, $environment ) || + ( ! empty( $state['status'] ) && 'running' === $state['status'] && time() - (int) $state['updated_at'] <= 60 ) + ) { + return new WP_Error( 'pmpro_deprecated_gateway_already_running', __( 'A workflow is already queued or running for this gateway.', 'paid-memberships-pro' ) ); + } + + // A unique, sortable ID for this run. Names the per-run migration CSV and lets + // each batch write to the same file. A new run (new schedule) gets a new ID. + $run_id = $gateway . '_' . $environment . '_' . gmdate( 'Ymd-His' ) . ( $dry_run ? '_dryrun' : '' ); + + // Start from a clean state so the new run's counters and feed are fresh. + delete_option( 'pmpro_deprecated_gateway_state_' . $gateway . '_' . $environment ); + pmpro_deprecated_gateway_update_state( + $gateway, + $environment, + array( + 'status' => 'running', + 'strategy' => $strategy, + 'send_email' => $send_email, + 'expire_past_due' => $expire_past_due, + 'dry_run' => $dry_run, + 'run_id' => $run_id, + 'started_at' => time(), + 'completed_at' => 0, + 'total' => $total, + 'processed' => 0, + 'complete' => 0, + 'skipped' => 0, + 'needs_review' => 0, + 'note' => '', + ) + ); + + // $unique guards against two concurrent start requests both passing the checks + // above and enqueueing parallel batch chains. Only the initial enqueue can be + // unique; uniqueness is per hook+group, so using it on the chained enqueue in + // pmpro_deprecated_gateway_process_batch() would block the next batch. + $action_id = as_enqueue_async_action( + 'pmpro_deprecated_gateway_process_batch', + array( $gateway, $environment, $strategy, $send_email ? 1 : 0, $expire_past_due ? 1 : 0, $dry_run ? 1 : 0, 0 ), + pmpro_deprecated_gateway_get_action_group( $gateway, $environment ), + true + ); + if ( empty( $action_id ) ) { + // A concurrent start request may have won the unique enqueue race, in which + // case its workflow is running now and the state should be left as is. + if ( pmpro_deprecated_gateway_has_scheduled_actions( $gateway, $environment ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_already_running', __( 'A workflow is already queued or running for this gateway.', 'paid-memberships-pro' ) ); + } + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => __( 'The workflow could not be scheduled.', 'paid-memberships-pro' ) ) ); + return new WP_Error( 'pmpro_deprecated_gateway_schedule_failed', __( 'The workflow could not be scheduled. Check the migration log for details.', 'paid-memberships-pro' ) ); + } + + // Create the per-run CSV with its header row now that the workflow is queued. + pmpro_deprecated_gateway_csv_init( $run_id ); + + pmpro_deprecated_gateway_log( sprintf( 'Queued deprecated gateway workflow. Gateway=%s, environment=%s, strategy=%s, send_email=%s, expire_past_due=%s, dry_run=%s, subscriptions=%d.', $gateway, $environment, $strategy, $send_email ? 'yes' : 'no', $expire_past_due ? 'yes' : 'no', $dry_run ? 'yes' : 'no', $total ) ); + + // Kick the Action Scheduler queue so the workflow starts right away. + if ( is_callable( array( 'PMPro_Action_Scheduler', 'dispatch_queue' ) ) ) { + PMPro_Action_Scheduler::dispatch_queue(); + } + + return true; +} + +/** + * Stop a queued or running workflow for the current environment. + * + * Pending batches are unscheduled immediately; a batch that is mid-run will + * finish its current group of subscriptions and then stop. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @return true|WP_Error + */ +function pmpro_deprecated_gateway_stop( $gateway ) { + $environment = pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ); + $gateway = sanitize_key( $gateway ); + + $state = pmpro_deprecated_gateway_get_state( $gateway, $environment ); + if ( empty( $state['status'] ) || 'running' !== $state['status'] ) { + return new WP_Error( 'pmpro_deprecated_gateway_not_running', __( 'No workflow is currently running for this gateway.', 'paid-memberships-pro' ) ); + } + + if ( function_exists( 'as_unschedule_all_actions' ) ) { + as_unschedule_all_actions( '', array(), pmpro_deprecated_gateway_get_action_group( $gateway, $environment ) ); + } + // A stopped dry run changed nothing, so restarting previews everything again. + $note = empty( $state['dry_run'] ) + ? __( 'Stopped by an administrator. Start the workflow again to continue; subscriptions that were already processed will be skipped.', 'paid-memberships-pro' ) + : __( 'Dry run stopped by an administrator. Start it again to preview the migration from the beginning.', 'paid-memberships-pro' ); + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => $note ) ); + pmpro_deprecated_gateway_log( 'Deprecated gateway workflow stopped by an administrator. Gateway=' . $gateway . ', environment=' . $environment . '.' ); + + return true; +} + +/** + * Process one batch of deprecated gateway subscriptions. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @param string $strategy Strategy slug. + * @param bool $send_email Whether to email members. + * @param bool $expire_past_due Expire the membership and cancel at the gateway for subscriptions + * whose next payment date is missing or in the past (instead of skipping them). + * @param bool $dry_run Preview the workflow without making changes. + * @param int $last_subscription_id Last subscription ID processed by the previous batch. + */ +function pmpro_deprecated_gateway_process_batch( $gateway, $environment, $strategy, $send_email = true, $expire_past_due = false, $dry_run = false, $last_subscription_id = 0 ) { + $gateway = sanitize_key( $gateway ); + $environment = sanitize_key( $environment ); + $strategy = sanitize_key( $strategy ); + $send_email = ! empty( $send_email ); + $expire_past_due = ! empty( $expire_past_due ); + $dry_run = ! empty( $dry_run ); + $last_subscription_id = (int) $last_subscription_id; + + // Never touch gateway APIs from a paused (likely cloned) site. This also protects + // against a database copied to staging while a workflow was queued on the live site. + if ( pmpro_is_paused() ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => __( 'Stopped because Paid Memberships Pro services are paused on this site.', 'paid-memberships-pro' ) ) ); + pmpro_deprecated_gateway_log( 'Stopped deprecated gateway workflow because PMPro services are paused. Gateway=' . $gateway . ', environment=' . $environment . '.' ); + return; + } + + // The state option is the control plane: stopping the workflow clears this flag. + $state = pmpro_deprecated_gateway_get_state( $gateway, $environment ); + if ( empty( $state['status'] ) || 'running' !== $state['status'] ) { + pmpro_deprecated_gateway_log( 'Skipped a deprecated gateway workflow batch because the workflow is no longer running. Gateway=' . $gateway . ', environment=' . $environment . '.' ); + return; + } + + // The gateway calls below all use the current environment's endpoints, so bail if + // the environment or replacement gateway changed since the workflow was scheduled. + if ( $environment !== pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ) + || ! in_array( $strategy, array( 'stripe', 'expiration' ), true ) + || $gateway === get_option( 'pmpro_gateway' ) + || ( 'stripe' === $strategy && 'stripe' !== get_option( 'pmpro_gateway' ) ) + ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => __( 'Stopped because the gateway environment or active gateway changed after the workflow was scheduled.', 'paid-memberships-pro' ) ) ); + pmpro_deprecated_gateway_log( 'Stopped deprecated gateway workflow because the gateway environment or active gateway changed. Gateway=' . $gateway . ', environment=' . $environment . ', strategy=' . $strategy . '.' ); + return; + } + + // Stripe credentials can be disconnected while a workflow is queued. Stop the + // run instead of recording a needs_review failure for every remaining + // subscription. + if ( 'stripe' === $strategy && ! empty( pmpro_deprecated_gateway_get_stripe_migration_blockers() ) ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => __( 'Stopped because Stripe is no longer connected for this gateway environment. Reconnect Stripe and start the workflow again to continue.', 'paid-memberships-pro' ) ) ); + pmpro_deprecated_gateway_log( 'Stopped deprecated gateway workflow because Stripe credentials are missing. Gateway=' . $gateway . ', environment=' . $environment . '.' ); + return; + } + + /** + * Filter the number of subscriptions processed per batch. + * + * Each batch chains the next one through Action Scheduler, so this controls + * how much work happens in a single request, not the total processed. The + * default is deliberately small: each Stripe migration can make several + * sequential gateway API calls, so a larger batch risks exceeding the PHP + * execution time limit mid-run, which would break the chain to the next batch. + * + * @since TBD + * + * @param int $batch_size Number of subscriptions per batch. + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + */ + $batch_size = (int) apply_filters( 'pmpro_deprecated_gateway_batch_size', 5, $gateway, $environment ); + $batch_size = $batch_size > 0 ? $batch_size : 5; + $subscription_ids = pmpro_deprecated_gateway_get_active_subscription_ids( $gateway, $environment, $last_subscription_id, $batch_size ); + if ( empty( $subscription_ids ) ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'completed', 'completed_at' => time() ) ); + pmpro_deprecated_gateway_log( ( $dry_run ? 'Deprecated gateway dry run completed; no changes were made. ' : 'Deprecated gateway workflow completed. ' ) . 'Gateway=' . $gateway . ', environment=' . $environment . '.' ); + return; + } + + $run_id = empty( $state['run_id'] ) ? '' : $state['run_id']; + foreach ( $subscription_ids as $subscription_id ) { + $result = pmpro_deprecated_gateway_process_subscription( (int) $subscription_id, $gateway, $environment, $strategy, $send_email, $expire_past_due, $dry_run ); + pmpro_deprecated_gateway_record_result( $gateway, $environment, $result['outcome'], ( $dry_run ? 'Dry run: ' : '' ) . $result['message'] ); + pmpro_deprecated_gateway_csv_append( $run_id, (int) $subscription_id, $result ); + } + + if ( count( $subscription_ids ) < $batch_size ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'completed', 'completed_at' => time() ) ); + pmpro_deprecated_gateway_log( ( $dry_run ? 'Deprecated gateway dry run completed; no changes were made. ' : 'Deprecated gateway workflow completed. ' ) . 'Gateway=' . $gateway . ', environment=' . $environment . '.' ); + return; + } + + $action_id = as_enqueue_async_action( + 'pmpro_deprecated_gateway_process_batch', + array( $gateway, $environment, $strategy, $send_email ? 1 : 0, $expire_past_due ? 1 : 0, $dry_run ? 1 : 0, max( $subscription_ids ) ), + pmpro_deprecated_gateway_get_action_group( $gateway, $environment ) + ); + if ( empty( $action_id ) ) { + pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => __( 'The next batch could not be queued. Start the workflow again to continue.', 'paid-memberships-pro' ) ) ); + pmpro_deprecated_gateway_log( 'Could not queue the next deprecated gateway workflow batch. Gateway=' . $gateway . ', environment=' . $environment . '.' ); + } +} +add_action( 'pmpro_deprecated_gateway_process_batch', 'pmpro_deprecated_gateway_process_batch', 10, 7 ); + + +/** + * Get the number of payments remaining on a billing-limited subscription. + * + * Billing limits do not count the subscription's initial checkout order, so a + * subscription allows billing_limit + 1 successful orders in total. One initial + * order is assumed even if it predates the recorded order history. + * + * @since TBD + * + * @param PMPro_Subscription $subscription The subscription to check. + * @return int Remaining payments. 0 or less means the limit is already reached. + */ +function pmpro_deprecated_gateway_get_remaining_payments( $subscription ) { + $billing_limit = (int) $subscription->get_billing_limit(); + $paid_orders = count( $subscription->get_orders( array( 'status' => 'success', 'limit' => $billing_limit + 2 ) ) ); + return $billing_limit + 1 - max( 1, $paid_orders ); +} + +/** + * Get identifying details for a subscription log entry. + * + * @since TBD + * + * @param PMPro_Subscription $subscription The subscription to describe. + * @return string Subscription details for logs. + */ +function pmpro_deprecated_gateway_get_subscription_log_description( $subscription ) { + $user = get_userdata( $subscription->get_user_id() ); + + if ( empty( $user ) ) { + $user_description = 'user #' . $subscription->get_user_id() . ' deleted'; + } else { + $user_description = 'user ' . $user->user_login . ' <' . $user->user_email . '>'; + } + + $subscription_transaction_id = (string) $subscription->get_subscription_transaction_id(); + if ( '' === $subscription_transaction_id ) { + $subscription_transaction_id = 'not set'; + } + + return 'subscription #' . $subscription->get_id() + . ' (' . $user_description + . '; subscription transaction ID: ' . $subscription_transaction_id . ')'; +} + +/** + * Build a process-subscription result, including the structured fields recorded + * in the per-run migration CSV. + * + * @since TBD + * + * @param string $outcome One of 'complete', 'skipped', 'needs_review'. + * @param string $message Log message. + * @param string $action The action taken (or attempted) for this subscription, for + * the CSV (e.g. 'migrated_to_stripe', 'membership_expired', + * 'cancelled_no_migration', 'cancelled_limit_reached', + * 'skipped_missed_payment', 'skipped_not_applicable', 'skipped_deleted'). + * This is the intended path; the 'outcome' field reports whether it + * succeeded ('complete'), was 'skipped', or 'needs_review'. + * @param array $extra Optional CSV fields: 'handoff_date', 'new_subscription_id', + * 'new_subscription_transaction_id', 'email_sent'. + * @return array + */ +function pmpro_deprecated_gateway_subscription_result( $outcome, $message, $action, $extra = array() ) { + return array_merge( + array( + 'outcome' => $outcome, + 'message' => $message, + 'action' => $action, + 'handoff_date' => '', + 'new_subscription_id' => '', + 'new_subscription_transaction_id' => '', + 'email_sent' => '', + ), + $extra + ); +} + +/** + * Process one deprecated gateway subscription. + * + * Order of operations is deliberate: create the replacement (Stripe placeholder + * or expiration date) first, email the member second, and cancel the old gateway + * subscription last. Cancelling flips the local status permanently, so anything + * that must happen for the member needs to happen before that final step. + * + * @since TBD + * + * @param int $subscription_id Subscription ID. + * @param string $gateway Gateway slug. + * @param string $environment Gateway environment. + * @param string $strategy Strategy slug. + * @param bool $send_email Whether to email members. + * @param bool $expire_past_due Expire the membership and cancel at the gateway for subscriptions + * whose next payment date is missing or in the past (instead of skipping them). + * @param bool $dry_run Report what would happen without making changes. No + * gateway calls, emails, or database writes; gateway-side failures + * can only be detected by a real run. + * @return array See pmpro_deprecated_gateway_subscription_result() for the shape. + */ +function pmpro_deprecated_gateway_process_subscription( $subscription_id, $gateway, $environment, $strategy, $send_email = true, $expire_past_due = false, $dry_run = false ) { + $subscription = PMPro_Subscription::get_subscription( $subscription_id ); + if ( empty( $subscription ) ) { + return pmpro_deprecated_gateway_subscription_result( 'skipped', 'Subscription #' . $subscription_id . ' no longer exists.', 'skipped_deleted' ); + } + + // Used in every log message so entries can be traced back to the member and + // the subscription at the gateway without cross-referencing IDs. + $subscription_description = pmpro_deprecated_gateway_get_subscription_log_description( $subscription ); + + if ( 'active' !== $subscription->get_status() || $gateway !== $subscription->get_gateway() || $environment !== pmpro_deprecated_gateway_normalize_environment( $subscription->get_gateway_environment() ) ) { + return pmpro_deprecated_gateway_subscription_result( 'skipped', ucfirst( $subscription_description ) . ' is no longer an active subscription for this gateway and environment.', 'skipped_not_applicable' ); + } + + // If the user no longer has the level, just cancel the old gateway subscription. + if ( ! pmpro_hasMembershipLevel( $subscription->get_membership_level_id(), $subscription->get_user_id() ) ) { + if ( $dry_run ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'would cancel ' . $subscription_description . ' without migration because the user no longer has the associated membership level.', 'cancelled_no_migration' ); + } + if ( $subscription->cancel_at_gateway() ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'Cancelled ' . $subscription_description . ' without migration because the user no longer has the associated membership level.', 'cancelled_no_migration' ); + } + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Could not confirm cancellation of ' . $subscription_description . ' at the gateway. The user no longer has the associated membership level. Verify this subscription in the gateway; an error email was sent to the admin.', 'cancelled_no_migration' ); + } + + // A subscription with Stripe placeholder meta is already mid-migration from an + // earlier run. A dry run reports that a real run would resume it instead of + // running the recovery logic below, which clears and rewrites meta. + if ( $dry_run && ( get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_subscription_id', true ) || get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_transaction_id', true ) ) ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', ucfirst( $subscription_description ) . ' is already mid-migration from an earlier run. A real run would complete the handoff to Stripe and cancel the old gateway subscription.', 'migrated_to_stripe' ); + } + + // Load a Stripe placeholder created by an earlier run, if any. If one exists, + // this member is on the Stripe path even if the strategy has since changed. + $placeholder = null; + $placeholder_id = (int) get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_subscription_id', true ); + if ( ! empty( $placeholder_id ) ) { + $placeholder = PMPro_Subscription::get_subscription( $placeholder_id ); + if ( ! empty( $placeholder ) && 'active' !== $placeholder->get_status() ) { + // The placeholder died since the last run (e.g. its trial ended without a + // payment method). Clear the stale references so a fresh one is created + // and the member is emailed again with the new dates. Bump the attempt + // counter so the next Stripe create call uses a fresh idempotency key; + // reusing the old key within 24 hours would make Stripe replay the + // original response and hand back the dead subscription. + update_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_attempt', (int) get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_attempt', true ) + 1 ); + delete_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_subscription_id' ); + delete_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_transaction_id' ); + delete_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_email_sent' ); + $placeholder = null; + } + } + + // A Stripe subscription created by an earlier run that failed before saving the + // local record. Loaded here so the check below never mistakes its local record + // (created but not yet linked by meta when the earlier run died) for an + // unrelated subscription. + $transaction_id = (string) get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_transaction_id', true ); + + // If the user already has another active subscription for this level (e.g. they + // already checked out again on the new gateway), just cancel the old gateway + // subscription instead of migrating or setting an expiration date. Only + // subscriptions in the same environment count: a leftover sandbox subscription + // must never decide the fate of a live one. + $other_subscriptions = PMPro_Subscription::get_subscriptions_for_user( $subscription->get_user_id(), $subscription->get_membership_level_id() ); + foreach ( $other_subscriptions as $other_subscription ) { + if ( + $environment !== pmpro_deprecated_gateway_normalize_environment( $other_subscription->get_gateway_environment() ) + || (int) $other_subscription->get_id() === (int) $subscription_id + || ( ! empty( $placeholder ) && (int) $other_subscription->get_id() === (int) $placeholder->get_id() ) + || ( '' !== $transaction_id && (string) $other_subscription->get_subscription_transaction_id() === $transaction_id ) + ) { + continue; + } + if ( $dry_run ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'would cancel ' . $subscription_description . ' without migration because the user already has active subscription #' . $other_subscription->get_id() . ' for this level.', 'cancelled_no_migration' ); + } + if ( $subscription->cancel_at_gateway() ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'Cancelled ' . $subscription_description . ' without migration because the user already has active subscription #' . $other_subscription->get_id() . ' for this level.', 'cancelled_no_migration' ); + } + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Could not confirm cancellation of ' . $subscription_description . ' at the gateway. The user already has active subscription #' . $other_subscription->get_id() . ' for this level. Verify this subscription in the gateway; an error email was sent to the admin.', 'cancelled_no_migration' ); + } + + // The next payment date is the handoff point: when the new Stripe subscription + // starts billing or when the membership expires. It is only needed when a + // replacement has not been created yet by an earlier run. An active subscription + // should always have an upcoming payment date, so a missing or past date is + // treated the same: it usually means a payment notification (IPN/webhook) was + // missed and the gateway may actually still be billing, so these are skipped by + // default and only expired when the admin explicitly opts in. + $handoff_timestamp = $subscription->get_next_payment_date( 'timestamp', false ); + $expire_this_subscription = false; + if ( empty( $placeholder ) && ( empty( $handoff_timestamp ) || $handoff_timestamp <= time() ) ) { + if ( ! $expire_past_due ) { + return pmpro_deprecated_gateway_subscription_result( 'skipped', ucfirst( $subscription_description ) . ' has a next payment date in the past (or none at all). This is often caused by a missed IPN or webhook, so the gateway may still be billing it. Verify this subscription at the gateway and migrate it manually, or run the migration again set to cancel and expire subscriptions with a missed payment.', 'skipped_missed_payment' ); + } + // Admin chose to expire: set the expiration to the missed payment date and cancel below. + $expire_this_subscription = true; + $handoff_timestamp = empty( $handoff_timestamp ) ? time() : $handoff_timestamp; + } + + $use_stripe = ( 'stripe' === $strategy && ! $expire_this_subscription ) || ! empty( $placeholder ); + + // Set below if the $0 billing limit bridge order cannot be saved. + $bridge_note = ''; + $bridge_needs_review = false; + + if ( $use_stripe ) { + if ( empty( $placeholder ) ) { + // Billing limits migrate as a remaining-payment count on the new + // subscription, enforced locally by order counting just like native + // Stripe subscriptions. + $remaining_payments = 0; + if ( ! empty( $subscription->get_billing_limit() ) ) { + $remaining_payments = pmpro_deprecated_gateway_get_remaining_payments( $subscription ); + if ( $remaining_payments < 1 ) { + // The limit was already reached, so no replacement is needed and + // the membership is left unchanged, matching billing limit semantics. + if ( $dry_run ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'would cancel ' . $subscription_description . ' without migration because its billing limit has already been reached. The membership would be left unchanged and no email would be sent.', 'cancelled_limit_reached' ); + } + if ( $subscription->cancel_at_gateway() ) { + return pmpro_deprecated_gateway_subscription_result( 'complete', 'Cancelled ' . $subscription_description . ' without migration because its billing limit has already been reached. The membership was left unchanged and no email was sent.', 'cancelled_limit_reached' ); + } + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Could not confirm cancellation of ' . $subscription_description . ' at the gateway. Its billing limit has already been reached. Verify this subscription in the gateway; an error email was sent to the admin.', 'cancelled_limit_reached' ); + } + } + + // If an earlier run created the Stripe subscription but failed before saving + // the local record, $transaction_id (loaded above) lets us reuse it instead + // of creating a duplicate at Stripe. + if ( '' === $transaction_id ) { + if ( ! class_exists( 'PMProGateway_stripe' ) || ! method_exists( 'PMProGateway_stripe', 'create_deprecated_gateway_migration_subscription' ) ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Could not create a Stripe placeholder for ' . $subscription_description . ' because the Stripe gateway is not available.', 'migrated_to_stripe' ); + } + if ( $dry_run ) { + // No Stripe calls in a dry run. Replicate the create call's local + // validations so data problems a real run would hit still show up + // in the preview; Stripe-side failures only surface in a real run. + if ( empty( get_userdata( $subscription->get_user_id() ) ) ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'could not create a Stripe placeholder for ' . $subscription_description . ' because the user no longer exists.', 'migrated_to_stripe' ); + } + $dry_run_level = new PMPro_Membership_Level( $subscription->get_membership_level_id() ); + if ( empty( $dry_run_level->ID ) ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'could not create a Stripe placeholder for ' . $subscription_description . ' because the membership level no longer exists.', 'migrated_to_stripe' ); + } + } else { + $stripe_gateway = new PMProGateway_stripe( 'stripe' ); + $stripe_api_subscription = $stripe_gateway->create_deprecated_gateway_migration_subscription( + $subscription, + array( + 'trial_end' => $handoff_timestamp, + 'attempt' => (int) get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_attempt', true ), + ) + ); + if ( is_wp_error( $stripe_api_subscription ) ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Could not create a Stripe placeholder for ' . $subscription_description . '. Error: ' . $stripe_api_subscription->get_error_message(), 'migrated_to_stripe' ); + } + $transaction_id = $stripe_api_subscription->id; + update_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_transaction_id', $transaction_id ); + } + } + + // Everything below saves the new placeholder, so a dry run is done with + // this subscription once the validations above have passed. + if ( ! $dry_run ) { + // The Stripe webhook or an earlier run may have already created the local + // record. Only adopt an active one: a cancelled record with this + // transaction ID means the Stripe subscription is dead, and create() + // below will return null for it, surfacing this as needs_review instead + // of silently migrating the member onto a dead subscription. + $placeholder = PMPro_Subscription::get_subscription( + array( + 'subscription_transaction_id' => $transaction_id, + 'gateway' => 'stripe', + 'gateway_environment' => $environment, + 'status' => 'active', + ) + ); + if ( empty( $placeholder ) ) { + $placeholder = PMPro_Subscription::create( + array( + 'user_id' => $subscription->get_user_id(), + 'membership_level_id' => $subscription->get_membership_level_id(), + 'gateway' => 'stripe', + 'gateway_environment' => $environment, + 'subscription_transaction_id' => $transaction_id, + 'status' => 'active', + 'startdate' => gmdate( 'Y-m-d H:i:s' ), + 'next_payment_date' => gmdate( 'Y-m-d H:i:s', $handoff_timestamp ), + 'billing_amount' => $subscription->get_billing_amount(), + 'cycle_number' => $subscription->get_cycle_number(), + 'cycle_period' => $subscription->get_cycle_period(), + 'billing_limit' => $remaining_payments, + ) + ); + } + if ( empty( $placeholder ) ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', 'Created Stripe subscription ' . $transaction_id . ' for ' . $subscription_description . ', but the local PMPro subscription record could not be created. Verify this subscription in Stripe and PMPro.', 'migrated_to_stripe', array( 'new_subscription_transaction_id' => $transaction_id ) ); + } + + update_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_stripe_subscription_id', $placeholder->get_id() ); + update_pmpro_subscription_meta( $placeholder->get_id(), 'deprecated_gateway_old_subscription_id', $subscription_id ); + pmpro_deprecated_gateway_flag_needs_payment_method( $placeholder->get_id() ); + + // Billing limits do not count a subscription's initial order, but a + // migrated subscription never has one. Record a $0 migration order so + // the remaining-payment count is enforced exactly. The lookup mirrors + // billing limit enforcement, which only counts successful orders. + if ( ! empty( $placeholder->get_billing_limit() ) && empty( $placeholder->get_orders( array( 'status' => 'success', 'limit' => 1 ) ) ) ) { + $bridge_order = new MemberOrder(); + $bridge_order->user_id = $subscription->get_user_id(); + $bridge_order->membership_id = $subscription->get_membership_level_id(); + $bridge_order->gateway = 'stripe'; + $bridge_order->gateway_environment = $environment; + $bridge_order->subscription_transaction_id = $transaction_id; + $bridge_order->total = 0; + $bridge_order->status = 'success'; + $bridge_order->notes = 'Deprecated gateway migration: stands in for the original checkout order of ' . $gateway . ' ' . $subscription_description . ' so the remaining billing limit of ' . (int) $placeholder->get_billing_limit() . ' is enforced.'; + if ( ! $bridge_order->saveOrder() ) { + // Reruns never reach this branch again once the placeholder exists, + // so a silent failure here would permanently under-enforce the limit. + $bridge_note = ' Could not save the $0 billing limit order, so the billing limit may allow one extra payment; review this subscription\'s billing limit in PMPro.'; + $bridge_needs_review = true; + } + } + } + } + } elseif ( ! $dry_run ) { + pmpro_set_expiration_date( $subscription->get_user_id(), $subscription->get_membership_level_id(), $handoff_timestamp ); + } + if ( ! $dry_run && ! empty( $handoff_timestamp ) ) { + update_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_handoff_date', gmdate( 'Y-m-d H:i:s', $handoff_timestamp ) ); + } + + // Email the member before cancelling so a cancellation failure never leaves + // a member unnotified. A meta flag prevents duplicate emails across reruns. + $email_note = ''; + $email_needs_review = false; + $already_emailed = (bool) get_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_email_sent', true ); + // CSV field: 'yes' if the member has been emailed (now or on an earlier run), + // 'no' if not, 'failed' if a send was attempted and failed, 'would' on a dry run. + $email_sent_status = $already_emailed ? 'yes' : 'no'; + if ( $send_email && ! $already_emailed ) { + $user = get_userdata( $subscription->get_user_id() ); + if ( empty( $user ) ) { + $email_note = ' Could not email the member because the user no longer exists.'; + $email_needs_review = true; + $email_sent_status = 'failed'; + } elseif ( $dry_run ) { + $email_note = ' Would email the member.'; + $email_sent_status = 'would'; + } else { + $email = $use_stripe + ? new PMPro_Email_Template_Deprecated_Gateway_Stripe_Migration( $placeholder ) + : new PMPro_Email_Template_Deprecated_Gateway_Checkout_Required( $user, (int) $subscription->get_membership_level_id(), (int) $handoff_timestamp ); + if ( $email->send() ) { + update_pmpro_subscription_meta( $subscription_id, 'deprecated_gateway_email_sent', time() ); + $email_note = ' Emailed the member.'; + $email_sent_status = 'yes'; + } else { + $email_note = ' The member email failed to send.'; + $email_needs_review = true; + $email_sent_status = 'failed'; + } + } + } + + // Shared CSV fields for the final outcomes below. + $handoff_date = empty( $handoff_timestamp ) ? '' : gmdate( 'Y-m-d', $handoff_timestamp ); + + // A dry run reaching this point is always a fresh migration: mid-migration + // subscriptions returned earlier, so $placeholder is null and + // $handoff_timestamp is set. + if ( $dry_run ) { + $outcome = $email_needs_review ? 'needs_review' : 'complete'; + if ( $use_stripe ) { + return pmpro_deprecated_gateway_subscription_result( $outcome, 'would migrate ' . $subscription_description . ' to a new Stripe placeholder subscription (no payment method, trial ending ' . gmdate( 'Y-m-d', $handoff_timestamp ) . ( empty( $remaining_payments ) ? '' : ', remaining billing limit of ' . $remaining_payments ) . ') and cancel the old gateway subscription.' . $email_note, 'migrated_to_stripe', array( 'handoff_date' => $handoff_date, 'email_sent' => $email_sent_status ) ); + } + return pmpro_deprecated_gateway_subscription_result( $outcome, ( $expire_this_subscription ? 'missed payment: would set' : 'would set' ) . ' the membership expiration date for ' . $subscription_description . ' to ' . gmdate( 'Y-m-d', $handoff_timestamp ) . ' and cancel the old gateway subscription.' . $email_note, 'membership_expired', array( 'handoff_date' => $handoff_date, 'email_sent' => $email_sent_status ) ); + } + + if ( ! $subscription->cancel_at_gateway() ) { + return pmpro_deprecated_gateway_subscription_result( 'needs_review', ( $expire_this_subscription ? 'Missed payment: set the membership expiration date for ' . $subscription_description . ', but could not confirm cancellation' : 'Could not confirm cancellation of ' . $subscription_description ) . ' at the gateway. Verify this subscription in the gateway; an error email was sent to the admin.' . $bridge_note . $email_note, $use_stripe ? 'migrated_to_stripe' : 'membership_expired', array( 'handoff_date' => $handoff_date, 'new_subscription_id' => ( $use_stripe && ! empty( $placeholder ) ) ? $placeholder->get_id() : '', 'new_subscription_transaction_id' => $use_stripe ? $transaction_id : '', 'email_sent' => $email_sent_status ) ); + } + + $outcome = ( $email_needs_review || $bridge_needs_review ) ? 'needs_review' : 'complete'; + if ( $use_stripe ) { + return pmpro_deprecated_gateway_subscription_result( $outcome, 'Migrated ' . $subscription_description . ' to Stripe placeholder subscription #' . $placeholder->get_id() . ' and cancelled the old gateway subscription.' . $bridge_note . $email_note, 'migrated_to_stripe', array( 'handoff_date' => $handoff_date, 'new_subscription_id' => $placeholder->get_id(), 'new_subscription_transaction_id' => $transaction_id, 'email_sent' => $email_sent_status ) ); + } + return pmpro_deprecated_gateway_subscription_result( $outcome, ( $expire_this_subscription ? 'Missed payment: set' : 'Set' ) . ' the membership expiration date for ' . $subscription_description . ' and cancelled the old gateway subscription.' . $bridge_note . $email_note, 'membership_expired', array( 'handoff_date' => $handoff_date, 'email_sent' => $email_sent_status ) ); +} + +/** + * Clean up a deprecated gateway after its subscriptions are handled. + * + * Live subscriptions block cleanup no matter which environment is currently + * selected: the stored credentials are shared by both environments, and + * deleting them would strand any remaining live subscriptions. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @return true|WP_Error + */ +function pmpro_deprecated_gateway_cleanup_gateway( $gateway ) { + $gateway = sanitize_key( $gateway ); + + if ( ! in_array( $gateway, pmpro_get_deprecated_gateways(), true ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_cleanup_not_deprecated', __( 'This workflow is only available for deprecated gateways.', 'paid-memberships-pro' ) ); + } + + if ( pmpro_is_paused() ) { + return new WP_Error( 'pmpro_deprecated_gateway_cleanup_paused', __( 'Paid Memberships Pro services are paused because this looks like a staging or development copy of your site. Resume services before removing gateway data.', 'paid-memberships-pro' ) ); + } + + if ( $gateway === get_option( 'pmpro_gateway' ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_cleanup_no_replacement', __( 'A different gateway must be active before this gateway can be removed.', 'paid-memberships-pro' ) ); + } + + $counts = pmpro_deprecated_gateway_get_subscription_counts( $gateway ); + if ( ! empty( $counts['live'] ) ) { + return new WP_Error( + 'pmpro_deprecated_gateway_cleanup_live_subscriptions', + sprintf( + // translators: %d: Number of live subscriptions. + _n( '%d live subscription is still active for this gateway. Migrate it before removing gateway data.', '%d live subscriptions are still active for this gateway. Migrate them before removing gateway data.', $counts['live'], 'paid-memberships-pro' ), + $counts['live'] + ) + ); + } + + // The panel only offers cleanup once both environments are at zero; enforce the + // same rule here so a direct request cannot orphan active sandbox subscriptions + // by unloading the gateway class they rely on. + if ( ! empty( $counts['sandbox'] ) ) { + return new WP_Error( + 'pmpro_deprecated_gateway_cleanup_sandbox_subscriptions', + sprintf( + // translators: %d: Number of sandbox subscriptions. + _n( '%d sandbox subscription is still active for this gateway. Process it in the sandbox environment before removing gateway data.', '%d sandbox subscriptions are still active for this gateway. Process them in the sandbox environment before removing gateway data.', $counts['sandbox'], 'paid-memberships-pro' ), + $counts['sandbox'] + ) + ); + } + + if ( pmpro_deprecated_gateway_has_scheduled_actions( $gateway, 'live' ) || pmpro_deprecated_gateway_has_scheduled_actions( $gateway, 'sandbox' ) ) { + return new WP_Error( 'pmpro_deprecated_gateway_cleanup_blocked', __( 'A workflow is queued or running for this gateway. Wait for it to finish or stop it before removing gateway data.', 'paid-memberships-pro' ) ); + } + + delete_option( 'pmpro_deprecated_gateway_state_' . $gateway . '_live' ); + delete_option( 'pmpro_deprecated_gateway_state_' . $gateway . '_sandbox' ); + + $undeprecated_gateways = array_values( array_diff( pmpro_get_undeprecated_gateways(), array( $gateway ) ) ); + update_option( 'pmpro_undeprecated_gateways', $undeprecated_gateways ); + + // Delete stored credentials. The PayPal gateways share one set of credentials, + // so only delete those once no PayPal gateway is loaded or active. + $option_names = array(); + $shared_paypal_gateways = array( 'paypalexpress', 'paypalwpp', 'paypalstandard' ); + if ( in_array( $gateway, $shared_paypal_gateways, true ) ) { + if ( empty( array_intersect( $shared_paypal_gateways, $undeprecated_gateways ) ) && ! in_array( get_option( 'pmpro_gateway' ), $shared_paypal_gateways, true ) ) { + $option_names = array( 'gateway_email', 'apiusername', 'apipassword', 'apisignature', 'paypalexpress_skip_confirmation' ); + } + } else { + switch ( $gateway ) { + case 'authorizenet': + $option_names = array( 'loginname', 'transactionkey', 'authnet_silent_post_token' ); + break; + case 'payflowpro': + $option_names = array( 'payflow_partner', 'payflow_vendor', 'payflow_user', 'payflow_pwd' ); + break; + case 'braintree': + $option_names = array( 'braintree_merchantid', 'braintree_publickey', 'braintree_privatekey', 'braintree_encryptionkey' ); + break; + case 'twocheckout': + $option_names = array( 'twocheckout_apiusername', 'twocheckout_apipassword', 'twocheckout_accountnumber', 'twocheckout_secretword' ); + break; + case 'cybersource': + $option_names = array( 'cybersource_merchantid', 'cybersource_securitykey' ); + break; + } + } + + foreach ( $option_names as $option_name ) { + delete_option( 'pmpro_' . $option_name ); + } + + // The per-run migration CSVs and the shared text log are intentionally kept as a + // record of the migration. + pmpro_deprecated_gateway_log( 'Deprecated gateway cleanup completed. Gateway=' . $gateway . '. Removed gateway from pmpro_undeprecated_gateways. Deleted options: ' . ( empty( $option_names ) ? 'none' : implode( ', ', $option_names ) ) . '.' ); + + return true; +} + +/** + * Flag a migrated subscription as having no payment method yet. + * + * Drives the member-facing account notice, the swapped recurring payment + * reminder email, and the admin "awaiting payment method" count and filter. + * + * @since TBD + * + * @param int $subscription_id The subscription to flag. + */ +function pmpro_deprecated_gateway_flag_needs_payment_method( $subscription_id ) { + update_pmpro_subscription_meta( (int) $subscription_id, 'deprecated_gateway_needs_payment_method', time() ); + delete_transient( 'pmpro_deprecated_gateway_needs_pm_count' ); +} + +/** + * Clear the no-payment-method flag for a migrated subscription. + * + * @since TBD + * + * @param int $subscription_id The subscription to clear. + */ +function pmpro_deprecated_gateway_clear_needs_payment_method( $subscription_id ) { + delete_pmpro_subscription_meta( (int) $subscription_id, 'deprecated_gateway_needs_payment_method' ); + delete_pmpro_subscription_meta( (int) $subscription_id, 'deprecated_gateway_needs_payment_method_checked' ); + delete_transient( 'pmpro_deprecated_gateway_needs_pm_count' ); +} + +/** + * Whether a migrated subscription is still waiting for a payment method. + * + * The local flag can go stale if a payment method is attached outside of PMPro + * (e.g. from the Stripe dashboard), so by default the flag is reverified + * against Stripe, throttled to one API call per subscription per six hours. + * The flag is cleared permanently as soon as a payment method is found. + * + * @since TBD + * + * @param PMPro_Subscription $subscription The subscription to check. + * @param bool $verify Whether to reverify the flag against Stripe. + * @param bool $skip_throttle Whether to verify even if recently checked. + * Use before acting on the flag in ways that are hard + * to take back, like emailing the member. + * @return bool + */ +function pmpro_deprecated_gateway_subscription_needs_payment_method( $subscription, $verify = true, $skip_throttle = false ) { + if ( empty( $subscription ) || ! is_a( $subscription, 'PMPro_Subscription' ) ) { + return false; + } + + if ( 'active' !== $subscription->get_status() || 'stripe' !== $subscription->get_gateway() ) { + return false; + } + + if ( ! get_pmpro_subscription_meta( $subscription->get_id(), 'deprecated_gateway_needs_payment_method', true ) ) { + return false; + } + + if ( ! $verify ) { + return true; + } + + // Only call Stripe when the active API keys match this subscription's environment. + if ( $subscription->get_gateway_environment() !== pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ) ) { + return true; + } + + if ( ! $skip_throttle ) { + $last_checked = (int) get_pmpro_subscription_meta( $subscription->get_id(), 'deprecated_gateway_needs_payment_method_checked', true ); + if ( ! empty( $last_checked ) && time() - $last_checked < 6 * HOUR_IN_SECONDS ) { + return true; + } + } + + if ( ! class_exists( 'PMProGateway_stripe' ) || ! method_exists( 'PMProGateway_stripe', 'subscription_has_payment_method' ) ) { + return true; + } + + $stripe_gateway = new PMProGateway_stripe( 'stripe' ); + if ( true === $stripe_gateway->subscription_has_payment_method( $subscription ) ) { + pmpro_deprecated_gateway_clear_needs_payment_method( $subscription->get_id() ); + return false; + } + + // No payment method, or the API call failed. Keep the flag and let the + // throttle prevent hammering Stripe. + update_pmpro_subscription_meta( $subscription->get_id(), 'deprecated_gateway_needs_payment_method_checked', time() ); + return true; +} + +/** + * Get the number of active subscriptions still waiting for a payment method. + * + * Cached briefly; the cache is invalidated whenever a flag is set or cleared. + * + * @since TBD + * + * @return int + */ +function pmpro_deprecated_gateway_get_needs_payment_method_count() { + global $wpdb; + + $count = get_transient( 'pmpro_deprecated_gateway_needs_pm_count' ); + if ( false === $count ) { + $count = (int) $wpdb->get_var( + "SELECT COUNT(*) + FROM {$wpdb->pmpro_subscriptions} s + INNER JOIN {$wpdb->pmpro_subscriptionmeta} sm + ON s.id = sm.pmpro_subscription_id + AND sm.meta_key = 'deprecated_gateway_needs_payment_method' + WHERE s.status = 'active'" + ); + set_transient( 'pmpro_deprecated_gateway_needs_pm_count', $count, 15 * MINUTE_IN_SECONDS ); + } + + return (int) $count; +} + +/** + * Clear the no-payment-method flag once an order proves a payment method exists. + * + * @since TBD + * + * @param MemberOrder $order The order whose subscription now has a payment method. + */ +function pmpro_deprecated_gateway_payment_method_updated( $order ) { + if ( empty( $order ) || ! is_a( $order, 'MemberOrder' ) ) { + return; + } + + $subscription = $order->get_subscription(); + if ( empty( $subscription ) ) { + return; + } + + if ( get_pmpro_subscription_meta( $subscription->get_id(), 'deprecated_gateway_needs_payment_method', true ) ) { + pmpro_deprecated_gateway_clear_needs_payment_method( $subscription->get_id() ); + } +} +add_action( 'pmpro_subscription_payment_completed', 'pmpro_deprecated_gateway_payment_method_updated' ); + +/** + * Clear the no-payment-method flag after a successful billing update. + * + * @since TBD + * + * @param int $user_id The user who updated their billing information. + * @param MemberOrder $order The order used for the billing update. + */ +function pmpro_deprecated_gateway_after_update_billing( $user_id, $order ) { + pmpro_deprecated_gateway_payment_method_updated( $order ); +} +add_action( 'pmpro_after_update_billing', 'pmpro_deprecated_gateway_after_update_billing', 10, 2 ); + +/** + * Reset the verification throttle when a member visits the billing update page + * for a flagged subscription. + * + * Sites using the Stripe Customer Portal add the payment method entirely on + * Stripe's side, so none of the local clearing hooks fire. Clearing the + * throttle before the portal redirect (which runs on this same action at + * priority 5) means the next account page view reverifies against Stripe + * immediately instead of showing a stale notice for up to six hours. + * + * @since TBD + */ +function pmpro_deprecated_gateway_billing_preheader_reset_throttle() { + global $pmpro_billing_subscription; + + if ( empty( $pmpro_billing_subscription ) || ! is_a( $pmpro_billing_subscription, 'PMPro_Subscription' ) ) { + return; + } + + if ( get_pmpro_subscription_meta( $pmpro_billing_subscription->get_id(), 'deprecated_gateway_needs_payment_method', true ) ) { + delete_pmpro_subscription_meta( $pmpro_billing_subscription->get_id(), 'deprecated_gateway_needs_payment_method_checked' ); + } +} +add_action( 'pmpro_billing_preheader', 'pmpro_deprecated_gateway_billing_preheader_reset_throttle', 1 ); + +/** + * Send the migration email in place of the generic recurring payment reminder + * for migrated subscriptions that still have no payment method. + * + * Without this, members who never added a payment method would receive a + * reminder implying their renewal will happen normally. This is also the + * reconciliation point: the flag is reverified against Stripe (no throttle) + * before the urgent email is sent. + * + * @since TBD + * + * @param bool $send_email Whether to send the generic reminder. + * @param PMPro_Subscription $subscription The subscription being reminded. + * @param int $days Days until the next payment. + * @return bool + */ +function pmpro_deprecated_gateway_swap_recurring_payment_reminder( $send_email, $subscription, $days ) { + if ( empty( $send_email ) ) { + return $send_email; + } + + if ( ! pmpro_deprecated_gateway_subscription_needs_payment_method( $subscription, true, true ) ) { + return $send_email; + } + + // If the migration email itself just went out (the migration ran inside the + // reminder window), don't send the same message again within days. + $old_subscription_id = (int) get_pmpro_subscription_meta( $subscription->get_id(), 'deprecated_gateway_old_subscription_id', true ); + if ( ! empty( $old_subscription_id ) ) { + $migration_email_sent = (int) get_pmpro_subscription_meta( $old_subscription_id, 'deprecated_gateway_email_sent', true ); + if ( ! empty( $migration_email_sent ) && time() - $migration_email_sent < 2 * DAY_IN_SECONDS ) { + return false; + } + } + + $email = new PMPro_Email_Template_Deprecated_Gateway_Stripe_Migration( $subscription ); + $email->send(); + + return false; +} +add_filter( 'pmpro_send_recurring_payment_reminder_email', 'pmpro_deprecated_gateway_swap_recurring_payment_reminder', 10, 3 ); + +/** + * Show an action-required notice on the Membership Account page for migrated + * subscriptions that still have no payment method. + * + * @since TBD + * + * @param object $level The level whose card is being rendered. + */ +function pmpro_deprecated_gateway_account_payment_method_notice( $level ) { + global $current_user; + + if ( empty( $current_user->ID ) || empty( $level->id ) ) { + return; + } + + $subscriptions = PMPro_Subscription::get_subscriptions_for_user( $current_user->ID, $level->id ); + foreach ( $subscriptions as $subscription ) { + if ( ! pmpro_deprecated_gateway_subscription_needs_payment_method( $subscription ) ) { + continue; + } + + $next_payment_date = $subscription->get_next_payment_date( get_option( 'date_format' ) ); + $billing_url = pmpro_url( 'billing', 'pmpro_subscription_id=' . (int) $subscription->get_id(), 'https' ); + ?> + + 'pmpro-subscriptions', + 'status' => 'needs_payment_method', + ), + admin_url( 'admin.php' ) + ); + ?> + + has_credentials() ) { + return array( __( 'Stripe is not connected for the current gateway environment, so subscriptions cannot be migrated to Stripe.', 'paid-memberships-pro' ) ); + } + + return array(); +} + +/** + * Get everything the deprecated gateway panel needs to render its current status. + * + * Used for both the initial page render and AJAX status polling. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @return array + */ +function pmpro_deprecated_gateway_get_status_data( $gateway ) { + $gateway = sanitize_key( $gateway ); + $environment = pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ); + $counts = pmpro_deprecated_gateway_get_subscription_counts( $gateway ); + $paused = pmpro_is_paused(); + $active = get_option( 'pmpro_gateway' ); + $has_actions = pmpro_deprecated_gateway_has_scheduled_actions( $gateway, $environment ); + $state = pmpro_deprecated_gateway_get_state( $gateway, $environment ); + + // Self-heal: if the state says running but nothing is queued or in progress + // (e.g. a batch action failed fatally), mark the workflow stopped so the admin + // can start it again. The 60 second grace period avoids racing a fresh start. + if ( ! empty( $state['status'] ) && 'running' === $state['status'] && ! $has_actions && time() - (int) $state['updated_at'] > 60 ) { + $note = empty( $state['dry_run'] ) + ? __( 'The workflow stopped unexpectedly. Start it again to continue; subscriptions that were already processed will be skipped.', 'paid-memberships-pro' ) + : __( 'The dry run stopped unexpectedly. Start it again to preview the migration from the beginning.', 'paid-memberships-pro' ); + $state = pmpro_deprecated_gateway_update_state( $gateway, $environment, array( 'status' => 'stopped', 'note' => $note ) ); + } + + $is_running = ! empty( $state['status'] ) && 'running' === $state['status']; + + $start_blockers = array(); + if ( $paused ) { + $start_blockers[] = __( 'Paid Memberships Pro services are paused because this looks like a staging or development copy of your site. Resume services to use this workflow.', 'paid-memberships-pro' ); + } + if ( $gateway === $active ) { + $start_blockers[] = __( 'Activate a different payment gateway before starting a migration.', 'paid-memberships-pro' ); + } + if ( empty( $counts[ $environment ] ) ) { + $start_blockers[] = __( 'There are no active subscriptions for this gateway in the current environment.', 'paid-memberships-pro' ); + $other_environment = 'live' === $environment ? 'sandbox' : 'live'; + if ( ! empty( $counts[ $other_environment ] ) ) { + $start_blockers[] = 'live' === $other_environment + ? __( 'To migrate the remaining live subscriptions, set the gateway environment to Live and reload this page.', 'paid-memberships-pro' ) + : __( 'To process the remaining sandbox subscriptions, set the gateway environment to Sandbox/Testing and reload this page.', 'paid-memberships-pro' ); + } + } + + $cleanup_blockers = array(); + if ( $paused ) { + $cleanup_blockers[] = __( 'Gateway data cannot be removed while Paid Memberships Pro services are paused.', 'paid-memberships-pro' ); + } + if ( $gateway === $active ) { + $cleanup_blockers[] = __( 'Gateway data cannot be removed while this is the active payment gateway.', 'paid-memberships-pro' ); + } + if ( ! empty( $counts['live'] ) ) { + $cleanup_blockers[] = sprintf( + // translators: %d: Number of live subscriptions. + _n( '%d live subscription is still active for this gateway. Live subscriptions must be migrated before gateway data can be removed, even while testing in the sandbox environment.', '%d live subscriptions are still active for this gateway. Live subscriptions must be migrated before gateway data can be removed, even while testing in the sandbox environment.', $counts['live'], 'paid-memberships-pro' ), + $counts['live'] + ); + } + if ( ! empty( $counts['sandbox'] ) ) { + $cleanup_blockers[] = sprintf( + // translators: %d: Number of sandbox subscriptions. + _n( '%d sandbox subscription is still active for this gateway. Process it in the sandbox environment before removing gateway data.', '%d sandbox subscriptions are still active for this gateway. Process them in the sandbox environment before removing gateway data.', $counts['sandbox'], 'paid-memberships-pro' ), + $counts['sandbox'] + ); + } + if ( $has_actions || pmpro_deprecated_gateway_has_scheduled_actions( $gateway, 'live' === $environment ? 'sandbox' : 'live' ) ) { + $cleanup_blockers[] = __( 'A workflow is queued or running for this gateway.', 'paid-memberships-pro' ); + } + + $workflow = null; + if ( ! empty( $state['status'] ) ) { + $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); + $workflow = array( + 'status' => $state['status'], + 'strategy' => empty( $state['strategy'] ) ? '' : $state['strategy'], + 'dry_run' => ! empty( $state['dry_run'] ), + 'total' => empty( $state['total'] ) ? 0 : (int) $state['total'], + 'processed' => empty( $state['processed'] ) ? 0 : (int) $state['processed'], + 'complete' => empty( $state['complete'] ) ? 0 : (int) $state['complete'], + 'skipped' => empty( $state['skipped'] ) ? 0 : (int) $state['skipped'], + 'needs_review' => empty( $state['needs_review'] ) ? 0 : (int) $state['needs_review'], + 'note' => empty( $state['note'] ) ? '' : $state['note'], + 'started_display' => empty( $state['started_at'] ) ? '' : wp_date( $datetime_format, (int) $state['started_at'] ), + 'completed_display' => empty( $state['completed_at'] ) ? '' : wp_date( $datetime_format, (int) $state['completed_at'] ), + 'csv_url' => empty( $state['run_id'] ) ? '' : pmpro_deprecated_gateway_get_csv_url( $state['run_id'] ), + ); + } + + // Only offer the Stripe strategy when Stripe can actually receive + // migrations; the blockers explain a disabled Stripe option to the admin. + $stripe_blockers = ( 'stripe' === $active && $gateway !== $active ) ? pmpro_deprecated_gateway_get_stripe_migration_blockers() : array(); + + return array( + 'gateway' => $gateway, + 'environment' => $environment, + 'counts' => $counts, + 'paused' => $paused, + 'has_replacement' => $gateway !== $active, + 'stripe_available' => 'stripe' === $active && $gateway !== $active && empty( $stripe_blockers ), + 'stripe_blockers' => $stripe_blockers, + 'workflow' => $workflow, + 'is_running' => $is_running, + 'can_start' => ! $is_running && empty( $start_blockers ), + 'start_blockers' => $start_blockers, + 'can_cleanup' => ! $is_running && empty( $cleanup_blockers ), + 'cleanup_blockers' => $cleanup_blockers, + // The full list of this gateway's run CSVs, surfaced at the cleanup step so + // admins have one place to download them (cleanup keeps them as a record). + // Skipped while a run is active to avoid globbing the logs dir on every poll. + 'csv_files' => $is_running ? array() : pmpro_deprecated_gateway_get_csv_files( $gateway ), + ); +} + +/** + * Handle AJAX requests from the deprecated gateway panel. + * + * @since TBD + */ +function pmpro_deprecated_gateway_ajax() { + if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'pmpro_paymentsettings' ) ) { + wp_send_json_error( array( 'message' => __( 'You do not have permissions to perform this action.', 'paid-memberships-pro' ) ), 403 ); + } + check_ajax_referer( 'pmpro_deprecated_gateway', 'nonce' ); + + $gateway = isset( $_POST['gateway'] ) ? sanitize_key( wp_unslash( $_POST['gateway'] ) ) : ''; + $task = isset( $_POST['task'] ) ? sanitize_key( wp_unslash( $_POST['task'] ) ) : 'status'; + if ( ! in_array( $gateway, pmpro_get_deprecated_gateways(), true ) ) { + wp_send_json_error( array( 'message' => __( 'This workflow is only available for deprecated gateways.', 'paid-memberships-pro' ) ), 400 ); + } + + $result = true; + $message = ''; + $redirect = ''; + switch ( $task ) { + case 'start': + $strategy = isset( $_POST['strategy'] ) ? sanitize_key( wp_unslash( $_POST['strategy'] ) ) : ''; + $send_email = empty( $_POST['skip_email'] ); + $expire_past_due = ! empty( $_POST['expire_past_due'] ); + $dry_run = ! empty( $_POST['dry_run'] ); + $result = pmpro_deprecated_gateway_schedule( $gateway, $strategy, $send_email, $expire_past_due, $dry_run ); + $message = $dry_run ? __( 'Dry run started. No changes will be made.', 'paid-memberships-pro' ) : __( 'Migration workflow started.', 'paid-memberships-pro' ); + break; + case 'stop': + $result = pmpro_deprecated_gateway_stop( $gateway ); + $message = __( 'Workflow stopped.', 'paid-memberships-pro' ); + break; + case 'cleanup': + $result = pmpro_deprecated_gateway_cleanup_gateway( $gateway ); + $message = __( 'Deprecated gateway data has been removed from this site.', 'paid-memberships-pro' ); + $redirect = add_query_arg( array( 'page' => 'pmpro-paymentsettings', 'deprecated_gateway_removed' => $gateway ), admin_url( 'admin.php' ) ); + break; + case 'activate_stripe': + $environment = pmpro_deprecated_gateway_normalize_environment( get_option( 'pmpro_gateway_environment', 'sandbox' ) ); + if ( ! class_exists( 'PMProGateway_stripe' ) || ! PMProGateway_stripe::has_connect_credentials( $environment ) ) { + $result = new WP_Error( 'pmpro_deprecated_gateway_stripe_not_connected', __( 'Connect to Stripe before making it the active gateway.', 'paid-memberships-pro' ) ); + } else { + update_option( 'pmpro_gateway', 'stripe' ); + + // Make sure a webhook is set up, like the Stripe Connect return flow does. + // Placeholder subscriptions rely on webhooks for renewal orders, billing + // limit enforcement, and syncing cancellations when a trial lapses. + $stripe_gateway = new PMProGateway_stripe(); + $update_webhook_response = $stripe_gateway->update_webhook_events(); + if ( empty( $update_webhook_response ) || is_wp_error( $update_webhook_response ) ) { + $result = new WP_Error( 'pmpro_deprecated_gateway_stripe_webhook', __( 'Stripe is now the active payment gateway, but a webhook could not be created automatically. Set up the webhook from the Stripe gateway settings before migrating subscriptions.', 'paid-memberships-pro' ) ); + } else { + $message = __( 'Stripe is now the active payment gateway.', 'paid-memberships-pro' ); + } + } + break; + case 'status': + break; + default: + wp_send_json_error( array( 'message' => __( 'Invalid task.', 'paid-memberships-pro' ) ), 400 ); + } + + $data = pmpro_deprecated_gateway_get_status_data( $gateway ); + if ( is_wp_error( $result ) ) { + $data['message'] = $result->get_error_message(); + $data['error'] = true; + } elseif ( ! empty( $message ) ) { + $data['message'] = $message; + $data['error'] = false; + if ( ! empty( $redirect ) ) { + $data['redirect'] = $redirect; + } + } + wp_send_json_success( $data ); +} +add_action( 'wp_ajax_pmpro_deprecated_gateway', 'pmpro_deprecated_gateway_ajax' ); + +/** + * Allow payment settings admins to view the migration log. + * + * @since TBD + * + * @param bool $can_access Whether the file can be accessed. + * @param string $file_dir File directory. + * @param string $file File name. + * @return bool + */ +function pmpro_deprecated_gateway_allow_log_access( $can_access, $file_dir, $file ) { + // Allow the global text log and any per-run CSV. The strict pattern (no slashes + // or extra dots) keeps this from being widened into a path traversal. + if ( 'logs' === $file_dir + && preg_match( '/^deprecated-gateways(-[A-Za-z0-9_\-]+)?\.(txt|csv)$/', (string) $file ) + && ( current_user_can( 'manage_options' ) || current_user_can( 'pmpro_paymentsettings' ) ) ) { + return true; + } + + return $can_access; +} +add_filter( 'pmpro_can_access_restricted_file', 'pmpro_deprecated_gateway_allow_log_access', 20, 3 ); + +/** + * Append a message to the migration log file. + * + * @since TBD + * + * @param string $logstr Log output. + */ +function pmpro_deprecated_gateway_log( $logstr ) { + $logstr = (string) $logstr; + if ( '' === $logstr ) { + return; + } + + $logfile = pmpro_get_restricted_file_path( 'logs', 'deprecated-gateways.txt' ); + if ( empty( $logfile ) ) { + return; + } + + $loghandle = fopen( $logfile, 'a+' ); + if ( $loghandle ) { + fwrite( $loghandle, '[' . date_i18n( 'Y-m-d H:i:s' ) . '] ' . $logstr . "\n" ); + fclose( $loghandle ); + } +} + +/** + * Column headings for the per-run migration CSV. + * + * @since TBD + * + * @return string[] + */ +function pmpro_deprecated_gateway_get_csv_columns() { + return array( + 'processed_date', + 'user_id', + 'user_email', + 'display_name', + 'membership_level', + 'old_subscription_id', + 'new_subscription_id', + 'new_subscription_transaction_id', + 'action', + 'handoff_date', + 'outcome', + 'email_sent', + 'notes', + ); +} + +/** + * Get the file name for a run's migration CSV. + * + * @since TBD + * + * @param string $run_id Run identifier stored in the workflow state. + * @return string Empty string if no run ID. + */ +function pmpro_deprecated_gateway_get_csv_filename( $run_id ) { + if ( empty( $run_id ) ) { + return ''; + } + return 'deprecated-gateways-' . sanitize_file_name( $run_id ) . '.csv'; +} + +/** + * Get the file path for a run's migration CSV. + * + * @since TBD + * + * @param string $run_id Run identifier. + * @return string Empty string if no run ID. + */ +function pmpro_deprecated_gateway_get_csv_path( $run_id ) { + $filename = pmpro_deprecated_gateway_get_csv_filename( $run_id ); + if ( empty( $filename ) ) { + return ''; + } + return pmpro_get_restricted_file_path( 'logs', $filename ); +} + +/** + * Get the download URL for a run's migration CSV. + * + * @since TBD + * + * @param string $run_id Run identifier. + * @return string Empty string if no run ID. + */ +function pmpro_deprecated_gateway_get_csv_url( $run_id ) { + $filename = pmpro_deprecated_gateway_get_csv_filename( $run_id ); + if ( empty( $filename ) ) { + return ''; + } + return add_query_arg( + array( + 'pmpro_restricted_file_dir' => 'logs', + 'pmpro_restricted_file' => $filename, + ), + admin_url( 'admin.php' ) + ); +} + +/** + * Create a run's migration CSV and write the header row. + * + * @since TBD + * + * @param string $run_id Run identifier. + */ +function pmpro_deprecated_gateway_csv_init( $run_id ) { + $path = pmpro_deprecated_gateway_get_csv_path( $run_id ); + if ( empty( $path ) ) { + return; + } + + $handle = fopen( $path, 'w' ); + if ( $handle ) { + // Pass the separator/enclosure/escape explicitly; relying on the default + // $escape is deprecated in PHP 8.4. + fputcsv( $handle, pmpro_deprecated_gateway_get_csv_columns(), ',', '"', '\\' ); + fclose( $handle ); + } +} + +/** + * Append one processed subscription to a run's migration CSV. + * + * Batches run sequentially (Action Scheduler concurrency is 1), so appending + * here needs no locking, just like the text log. + * + * @since TBD + * + * @param string $run_id Run identifier. + * @param int $subscription_id The subscription that was processed. + * @param array $result Result from pmpro_deprecated_gateway_process_subscription(). + */ +function pmpro_deprecated_gateway_csv_append( $run_id, $subscription_id, $result ) { + $path = pmpro_deprecated_gateway_get_csv_path( $run_id ); + if ( empty( $path ) ) { + return; + } + + $user_id = 0; + $user_email = ''; + $display_name = ''; + $membership_level = ''; + $old_subscription_id = (int) $subscription_id; + + $subscription = PMPro_Subscription::get_subscription( $subscription_id ); + if ( ! empty( $subscription ) ) { + $user_id = (int) $subscription->get_user_id(); + $user = get_userdata( $user_id ); + if ( ! empty( $user ) ) { + $user_email = $user->user_email; + $display_name = $user->display_name; + } + $level = pmpro_getLevel( $subscription->get_membership_level_id() ); + $membership_level = empty( $level ) ? '' : $level->name; + } + + $row = array( + date_i18n( 'Y-m-d H:i:s' ), + $user_id, + $user_email, + $display_name, + $membership_level, + $old_subscription_id, + isset( $result['new_subscription_id'] ) ? $result['new_subscription_id'] : '', + isset( $result['new_subscription_transaction_id'] ) ? $result['new_subscription_transaction_id'] : '', + isset( $result['action'] ) ? $result['action'] : '', + isset( $result['handoff_date'] ) ? $result['handoff_date'] : '', + isset( $result['outcome'] ) ? $result['outcome'] : '', + isset( $result['email_sent'] ) ? $result['email_sent'] : '', + isset( $result['message'] ) ? trim( (string) $result['message'] ) : '', + ); + + $handle = fopen( $path, 'a' ); + if ( $handle ) { + fputcsv( $handle, $row, ',', '"', '\\' ); + fclose( $handle ); + } +} + +/** + * Get the migration CSV files that exist for a gateway, newest first. + * + * @since TBD + * + * @param string $gateway Gateway slug. + * @return array[] Each entry has 'filename', 'url', and 'date' keys. + */ +function pmpro_deprecated_gateway_get_csv_files( $gateway ) { + $gateway = sanitize_key( $gateway ); + + // Resolve the logs directory from a known restricted-file path. + $sample_path = pmpro_get_restricted_file_path( 'logs', 'deprecated-gateways.txt' ); + if ( empty( $sample_path ) ) { + return array(); + } + + $matches = glob( dirname( $sample_path ) . '/deprecated-gateways-' . $gateway . '_*.csv' ); + if ( empty( $matches ) ) { + return array(); + } + + // Filenames embed a sortable YYYYMMDD-HHMMSS stamp, so reverse-sorting the + // paths puts the newest run first. + rsort( $matches ); + + $files = array(); + foreach ( $matches as $path ) { + $filename = basename( $path ); + // Pull the YYYYMMDD-HHMMSS stamp (UTC, set at schedule time) out of the + // filename for a readable label. + $date = ''; + if ( preg_match( '/_(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(_dryrun)?\.csv$/', $filename, $m ) ) { + $timestamp = strtotime( $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $m[6] . ' UTC' ); + $date = $timestamp ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ) : ''; + if ( ! empty( $m[7] ) ) { + $date .= ' (dry run)'; + } + } + $files[] = array( + 'filename' => $filename, + 'url' => add_query_arg( + array( + 'pmpro_restricted_file_dir' => 'logs', + 'pmpro_restricted_file' => $filename, + ), + admin_url( 'admin.php' ) + ), + 'date' => $date, + ); + } + + return $files; +} + +/** + * Render the deprecated gateway panel on the payment settings page. + * + * @since TBD + * + * @param string $gateway Gateway slug. + */ +function pmpro_deprecated_gateway_render_panel( $gateway ) { + $gateway = sanitize_key( $gateway ); + $data = pmpro_deprecated_gateway_get_status_data( $gateway ); + + $gateway_names = pmpro_gateways(); + $gateway_name = empty( $gateway_names[ $gateway ] ) ? $gateway : $gateway_names[ $gateway ]; + + $log_url = add_query_arg( + array( + 'pmpro_restricted_file_dir' => 'logs', + 'pmpro_restricted_file' => 'deprecated-gateways.txt', + ), + admin_url( 'admin.php' ) + ); + $stripe_template_url = add_query_arg( array( 'page' => 'pmpro-emailtemplates', 'edit' => 'deprecated_gateway_stripe_migration' ), admin_url( 'admin.php' ) ); + $checkout_template_url = add_query_arg( array( 'page' => 'pmpro-emailtemplates', 'edit' => 'deprecated_gateway_checkout_required' ), admin_url( 'admin.php' ) ); + + // For the "activate a new gateway" step, offer the same Stripe Connect flow used in the setup wizard. + $stripe_connected = class_exists( 'PMProGateway_stripe' ) && PMProGateway_stripe::has_connect_credentials( $data['environment'] ); + $stripe_connect_url = add_query_arg( + array( + 'action' => 'authorize', + 'gateway_environment' => 'live' === $data['environment'] ? 'live' : 'test', + 'return_url' => rawurlencode( add_query_arg( array( 'page' => 'pmpro-paymentsettings', 'edit_gateway' => $gateway, 'pmpro_stripe_connect_nonce' => wp_create_nonce( 'pmpro_stripe_connect_nonce' ) ), admin_url( 'admin.php' ) ) ), + ), + apply_filters( 'pmpro_stripe_connect_url', 'https://connect.paidmembershipspro.com' ) + ); + + $config = array( + 'gateway' => $gateway, + 'log_url' => $log_url, + 'nonce' => wp_create_nonce( 'pmpro_deprecated_gateway' ), + 'initial' => $data, + 'i18n' => array( + 'env_live' => __( 'Live environment', 'paid-memberships-pro' ), + 'env_sandbox' => __( 'Sandbox environment', 'paid-memberships-pro' ), + // translators: %s: number of live subscriptions. + 'finish_other_live' => __( '%s live subscriptions still need to be migrated. Switch the Gateway Environment to Live/Production, updating the gateway API keys as needed, then return to this page to migrate them.', 'paid-memberships-pro' ), + // translators: %s: number of sandbox subscriptions. + 'finish_other_sandbox' => __( '%s sandbox subscriptions still need to be migrated. Switch the Gateway Environment to Sandbox/Testing, updating the gateway API keys as needed, then return to this page to migrate them.', 'paid-memberships-pro' ), + 'no_workflow' => __( 'No workflow has been run for this gateway in the current environment yet.', 'paid-memberships-pro' ), + 'running' => __( 'Migration in progress', 'paid-memberships-pro' ), + 'running_dry' => __( 'Dry run in progress', 'paid-memberships-pro' ), + // translators: %1$s: number processed, %2$s: total number. + 'progress_of' => __( '%1$s of %2$s subscriptions processed', 'paid-memberships-pro' ), + // translators: %s: date and time. + 'completed_on' => __( 'Workflow completed %s', 'paid-memberships-pro' ), + // translators: %s: date and time. + 'completed_on_dry' => __( 'Dry run completed %s. No changes were made.', 'paid-memberships-pro' ), + // translators: %s: date and time. + 'started_on' => __( 'Started %s', 'paid-memberships-pro' ), + 'stopped' => __( 'Workflow stopped', 'paid-memberships-pro' ), + 'chip_complete' => __( 'Complete', 'paid-memberships-pro' ), + 'chip_skipped' => __( 'Skipped', 'paid-memberships-pro' ), + 'chip_needs_review' => __( 'Needs Review', 'paid-memberships-pro' ), + 'needs_review_warning' => __( 'Some subscriptions need review. Search the migration log for "[needs_review]" entries and review each note before removing gateway data.', 'paid-memberships-pro' ), + 'skipped_warning' => __( 'Some subscriptions were skipped. Search the migration log for "[skipped]" entries and handle them manually, or run the migration again and set "Subscriptions With a Missed Payment" to cancel and expire them.', 'paid-memberships-pro' ), + // translators: %1$s: number of subscriptions, %2$s: environment label. + 'confirm_start' => __( 'This will process %1$s active subscriptions in the %2$s and cancel them at the old gateway.', 'paid-memberships-pro' ), + // translators: %1$s: number of subscriptions, %2$s: environment label. + 'confirm_start_dry' => __( 'Dry run: this will preview the migration of %1$s active subscriptions in the %2$s and record the planned outcomes in the migration log. No changes will be made and no emails will be sent.', 'paid-memberships-pro' ), + 'confirm_start_email' => __( 'Members WILL be emailed.', 'paid-memberships-pro' ), + 'confirm_start_noemail' => __( 'Members will NOT be emailed.', 'paid-memberships-pro' ), + 'confirm_start_stripe' => __( 'Members who do not add a payment method before their next payment date will have their membership cancelled.', 'paid-memberships-pro' ), + 'confirm_start_expire_past_due' => __( 'Subscriptions with a missed payment (a next payment date in the past) will be cancelled and their memberships expired.', 'paid-memberships-pro' ), + 'confirm_continue' => __( 'Continue?', 'paid-memberships-pro' ), + 'download_log' => __( 'Download Migration Log', 'paid-memberships-pro' ), + 'download_csv' => __( 'Download Migration CSV', 'paid-memberships-pro' ), + 'csv_none' => __( 'No Migration CSV files were found for this gateway.', 'paid-memberships-pro' ), + 'confirm_stop' => __( 'Stop this workflow? Subscriptions that were already processed stay processed. You can start the workflow again later to continue.', 'paid-memberships-pro' ), + 'confirm_cleanup' => __( 'This will permanently delete the stored credentials for this gateway and stop loading it on this site. Your migration log and Migration CSVs are kept.', 'paid-memberships-pro' ), + 'error_generic' => __( 'Something went wrong. Please reload the page and try again.', 'paid-memberships-pro' ), + 'start_dry' => __( 'Preview Migration (Dry Run)', 'paid-memberships-pro' ), + 'start_real' => __( 'Start Real Migration', 'paid-memberships-pro' ), + ), + ); + ?> +