class-wcs-repair-2-0-2.php 19.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412
<?php
/**
 * Repair subscriptions data corrupted with the v2.0.0 upgrade process
 *
 * @author		Prospress
 * @category	Admin
 * @package		WooCommerce Subscriptions/Admin/Upgrades
 * @version		2.0.2
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly
}

class WCS_Repair_2_0_2 {

	/**
	 * Get a batch of subscriptions subscriptions that haven't already been checked for repair.
	 *
	 * @return array IDs of subscription that have not been checked or repaired
	 */
	public static function get_subscriptions_to_repair( $batch_size ) {

		// Get any subscriptions that haven't already been checked for repair
		$subscription_ids_to_repair = get_posts( array(
			'post_type'      => 'shop_subscription',
			'post_status'    => 'any',
			'posts_per_page' => $batch_size,
			'fields'         => 'ids',
			'orderby'        => 'ID',
			'order'          => 'ASC',
			'meta_query'     => array(
				array(
					'key'     => '_wcs_repaired_2_0_2',
					'compare' => 'NOT EXISTS',
				),
			),
		) );

		return $subscription_ids_to_repair;
	}

	/**
	 * Update any subscription that need to be repaired.
	 *
	 * @return array The counts of repaired and unrepaired subscriptions
	 */
	public static function maybe_repair_subscriptions( $subscription_ids_to_repair ) {
		global $wpdb;

		// don't allow data to be half upgraded on a subscription in case of a script timeout or other non-recoverable error
		$wpdb->query( 'START TRANSACTION' );

		$repaired_count = $unrepaired_count = 0;

		foreach ( $subscription_ids_to_repair as $subscription_id ) {

			$subscription = wcs_get_subscription( $subscription_id );

			if ( false !== $subscription && self::maybe_repair_subscription( $subscription ) ) {
				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair completed', $subscription->id ) );
				$repaired_count++;
				update_post_meta( $subscription_id, '_wcs_repaired_2_0_2', 'true' );
			} else {
				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no repair needed', $subscription->id ) );
				$unrepaired_count++;
				update_post_meta( $subscription_id, '_wcs_repaired_2_0_2', 'false' );
			}
		}

		$wpdb->query( 'COMMIT' );

		return array(
			'repaired_count'   => $repaired_count,
			'unrepaired_count' => $unrepaired_count,
		);

	}

	/**
	 * Check if a subscription was created prior to 2.0.0 and has some dates that need to be updated
	 * because the meta was borked during the 2.0.0 upgrade process. If it does, then update the dates
	 * to the new values.
	 *
	 * @return bool true if the subscription was repaired, otherwise false
	 */
	protected static function maybe_repair_subscription( $subscription ) {

		$repaired_subscription = false;

		// if the subscription doesn't have an order, it must have been created in 2.0, so we can ignore it
		if ( false === $subscription->order ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has no order.', $subscription->id ) );
			return $repaired_subscription;
		}

		$subscription_line_items = $subscription->get_items();

		// if the subscription has more than one line item, it must have been created in 2.0, so we can ignore it
		if ( count( $subscription_line_items ) > 1 ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has more than one line item.', $subscription->id ) );
			return $repaired_subscription;
		}

		$subscription_line_item_id = key( $subscription_line_items );
		$subscription_line_item    = array_shift( $subscription_line_items );

		// Get old order item's meta
		foreach ( $subscription->order->get_items() as $line_item_id => $line_item ) {
			if ( wcs_get_canonical_product_id( $line_item ) == wcs_get_canonical_product_id( $subscription_line_item ) ) {
				$matching_line_item_id = $line_item_id;
				$matching_line_item    = $line_item;
				break;
			}
		}

		// we couldn't find a matching line item so we can't repair it
		if ( ! isset( $matching_line_item ) ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: can not repair: it has no matching line item.', $subscription->id ) );
			return $repaired_subscription;
		}

		$matching_line_item_meta = $matching_line_item['item_meta'];

		// if the order item doesn't have migrated subscription data, the subscription wasn't migrated from 1.5
		if ( ! isset( $matching_line_item_meta['_wcs_migrated_subscription_status'] ) && ! isset( $matching_line_item_meta['_wcs_migrated_subscription_start_date'] )  ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: matching line item has no migrated meta data.', $subscription->id ) );
			return $repaired_subscription;
		}

		if ( false !== self::maybe_repair_line_tax_data( $subscription_line_item_id, $matching_line_item_id, $matching_line_item ) ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired missing line tax data.', $subscription->id ) );
			$repaired_subscription = true;
		} else {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: line tax data not added.', $subscription->id ) );
		}

		// if the subscription has been cancelled, we don't need to repair any other data
		if ( $subscription->has_status( array( 'pending-cancel', 'cancelled' ) ) ) {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair: it has cancelled status.', $subscription->id ) );
			return $repaired_subscription;
		}

		$dates_to_update = array();

		if ( false !== ( $repair_date = self::check_trial_end_date( $subscription, $matching_line_item_meta ) ) ) {
			$dates_to_update['trial_end'] = $repair_date;
		}

		if ( false !== ( $repair_date = self::check_next_payment_date( $subscription ) ) ) {
			$dates_to_update['next_payment'] = $repair_date;
		}

		if ( false !== ( $repair_date = self::check_end_date( $subscription, $matching_line_item_meta ) ) ) {
			$dates_to_update['end'] = $repair_date;
		}

		if ( ! empty( $dates_to_update ) ) {

			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repairing dates = %s', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ) ) );

			try {
				$subscription->update_dates( $dates_to_update );
				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired dates = %s', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ) ) );
			} catch ( Exception $e ) {
				WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair dates (%s), exception "%s"', $subscription->id, str_replace( array( '{', '}', '"' ), '', wcs_json_encode( $dates_to_update ) ), $e->getMessage() ) );
			}

			try {
				self::maybe_repair_status( $subscription, $matching_line_item_meta, $dates_to_update );
			} catch ( Exception $e ) {
				WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair status. Exception: "%s"', $subscription->id, $e->getMessage() ) );
			}

			$repaired_subscription = true;
		}

		if ( ! empty( $subscription->order->customer_note ) && empty( $subscription->customer_note ) ) {

			$post_data = array(
				'ID'           => $subscription->id,
				'post_excerpt' => $subscription->order->customer_note,
			);

			$updated_post_id = wp_update_post( $post_data, true );

			if ( ! is_wp_error( $updated_post_id ) ) {
				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired missing customer note.', $subscription->id ) );
				$repaired_subscription = true;
			} else {
				WCS_Upgrade_Logger::add( sprintf( '!! For subscription %d: unable to repair missing customer note. Exception: "%s"', $subscription->id, $updated_post_id->get_error_message() ) );
			}
		}

		return $repaired_subscription;
	}

	/**
	 * If we have a trial end date and that value is not the same as the old end date prior to upgrade, it was most likely
	 * corrupted, so we will reset it to the value in meta.
	 *
	 * @param  WC_Subscription $subscription the subscription to check
	 * @param  array $former_order_item_meta the order item meta data for the line item on the original order that formerly represented the subscription
	 * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired
	 */
	protected static function check_trial_end_date( $subscription, $former_order_item_meta ) {

		$new_trial_end_time = $subscription->get_time( 'trial_end' );

		if ( $new_trial_end_time > 0 ) {

			$old_trial_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_trial_expiry_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_trial_expiry_date'][0] : 0;

			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new trial end date = %s.', $subscription->id, var_export( $subscription->get_date( 'trial_end' ), true ) ) );
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old trial end date = %s.', $subscription->id, var_export( $old_trial_end_date, true ) ) );

			// if the subscription has a trial end time whereas previously it didn't, we need it to be deleted
			if ( 0 == $old_trial_end_date ) {
				$repair_date = 0;
			} else {
				$repair_date = false;
			}
		} else {
			$repair_date = false;
		}

		WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair trial end date = %s.', $subscription->id, var_export( $repair_date, true ) ) );

		return $repair_date;
	}

	/**
	 * Because the upgrader may have attempted to set an invalid end date on the subscription, it could
	 * lead to the entire date update process failing, which would mean that a next payment date would
	 * not be set even when one existed.
	 *
	 * This method checks if a given subscription has no next payment date, and if it doesn't, it checks
	 * if one was previously scheduled for the old subscription. If one was, and that date is in the future,
	 * it will pass that date back for being set on the subscription. If a date was scheduled but that is now
	 * in the past, it will recalculate it.
	 *
	 * @param  WC_Subscription $subscription the subscription to check
	 * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired
	 */
	protected static function check_next_payment_date( $subscription ) {
		global $wpdb;

		// the subscription doesn't have a next payment date set, let's see if it should
		if ( 0 == $subscription->get_time( 'next_payment' ) && $subscription->has_status( 'active' ) ) {

			$old_hook_args = array(
				'user_id'          => (int) $subscription->get_user_id(),
				'subscription_key' => wcs_get_old_subscription_key( $subscription ),
			);

			// get the latest scheduled subscription payment in v1.5
			$old_next_payment_date = $wpdb->get_var( $wpdb->prepare(
				"SELECT post_date_gmt FROM $wpdb->posts
				 WHERE post_type = %s
				 AND post_content = %s
				 AND post_title = 'scheduled_subscription_payment'
				 ORDER BY post_date_gmt DESC",
				ActionScheduler_wpPostStore::POST_TYPE,
				wcs_json_encode( $old_hook_args )
			) );

			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new next payment date = %s.', $subscription->id, var_export( $subscription->get_date( 'next_payment' ), true ) ) );
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old next payment date = %s.', $subscription->id, var_export( $old_next_payment_date, true ) ) );

			// if we have a date, make sure it's valid
			if ( null !== $old_next_payment_date ) {
				if ( strtotime( $old_next_payment_date ) <= gmdate( 'U' ) ) {
					$repair_date = $subscription->calculate_date( 'next_payment' );
					if ( 0 == $repair_date ) {
						$repair_date = false;
					}
					WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old next payment date is in the past, setting it to %s.', $subscription->id, var_export( $repair_date, true ) ) );
				} else {
					$repair_date = $old_next_payment_date;
				}
			} else {

				// let's just double check we shouldn't have a date set by recalculating it
				$calculated_next_payment_date = $subscription->calculate_date( 'next_payment' );

				if ( 0 != $calculated_next_payment_date && strtotime( $calculated_next_payment_date ) > gmdate( 'U' ) ) {
					$repair_date = $calculated_next_payment_date;
				} else {
					$repair_date = false;
				}

				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no old next payment date, setting it to %s.', $subscription->id, var_export( $repair_date, true ) ) );
			}
		} else {
			$repair_date = false;
		}

		WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair next payment date = %s.', $subscription->id, var_export( $repair_date, true ) ) );

		return $repair_date;
	}

	/**
	 * Check if the old subscription meta had an end date recorded and make sure that end date is now being used for the new subscription.
	 *
	 * In Subscriptions prior to 2.0 a subscription could have both an end date and an expiration date. The end date represented a date in the past
	 * on which the subscription expired or was cancelled. The expiration date represented a date on which the subscription was set to expire (this
	 * could be in the past or future and could be the same as the end date or different). Because the end date is a definitive even, in this function
	 * we first check if it exists before falling back to the expiration date to check against.
	 *
	 * @param  WC_Subscription $subscription the subscription to check
	 * @param  array $former_order_item_meta the order item meta data for the line item on the original order that formerly represented the subscription
	 * @return string|bool false if the date does not need to be repaired or the new date if it should be repaired
	 */
	protected static function check_end_date( $subscription, $former_order_item_meta ) {

		$new_end_time = $subscription->get_time( 'end' );

		if ( $new_end_time > 0 ) {

			$old_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_end_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_end_date'][0] : 0;

			// if the subscription hadn't expired or been cancelled yet, it wouldn't have an end date, but it may still have had an expiry date, so use that instead
			if ( 0 == $old_end_date ) {
				$old_end_date = isset( $former_order_item_meta['_wcs_migrated_subscription_expiry_date'][0] ) ? $former_order_item_meta['_wcs_migrated_subscription_expiry_date'][0] : 0;
			}

			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: new end date = %s.', $subscription->id, var_export( $subscription->get_date( 'end' ), true ) ) );
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: old end date = %s.', $subscription->id, var_export( $old_end_date, true ) ) );

			// if the subscription has an end time whereas previously it didn't, we need it to be deleted so set it 0
			if ( 0 == $old_end_date ) {
				$repair_date = 0;
			} else {
				$repair_date = false;
			}
		} else {
			$repair_date = false;
		}

		WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repair end date = %s.', $subscription->id, var_export( $repair_date, true ) ) );

		return $repair_date;
	}

	/**
	 * If the subscription has expired since upgrading and the end date is not the original expiration date,
	 * we need to unexpire it, which in the case of a previously active subscription means activate it, and
	 * in any other case, leave it as on-hold (a cancelled subscription wouldn't have been expired, so the
	 * status must be on-hold or active).
	 *
	 * @param  WC_Subscription $subscription data about the subscription
	 * @return bool true if the trial date was repaired, otherwise false
	 */
	protected static function maybe_repair_status( $subscription, $former_order_item_meta, $dates_to_update ) {

		if ( $subscription->has_status( 'expired' ) && 'expired' != $former_order_item_meta['_wcs_migrated_subscription_status'][0] && isset( $dates_to_update['end'] ) ) {

			try {

				// we need to bypass the update_status() method here because normally an expired subscription can't have it's status changed, we also don't want normal status change hooks to be fired
				wp_update_post( array( 'ID' => $subscription->id, 'post_status' => 'wc-on-hold' ) );

				// if the payment method doesn't support date changes, we still want to reactivate the subscription but we also need to process a special failed payment at the next renewal to fix up the payment method so we'll set a special flag in post meta to handle that
				if ( ! $subscription->payment_method_supports( 'subscription_date_changes' ) && $subscription->get_total() > 0 ) {
					update_post_meta( $subscription->id, '_wcs_repaired_2_0_2_needs_failed_payment', 'true' );
					WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: payment method does not support "subscription_date_changes" and total > 0, setting "_wcs_repaired_2_0_2_needs_failed_payment" post meta flag.', $subscription->id ) );
				}

				if ( 'active' == $former_order_item_meta['_wcs_migrated_subscription_status'][0] && $subscription->can_be_updated_to( 'active' ) ) {
					$subscription->update_status( 'active' );
				}

				WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: repaired status. Status was "expired", it is now "%s".', $subscription->id, $subscription->get_status() ) );
				$repair_status = true;

			} catch ( Exception $e ) {
				WCS_Upgrade_Logger::add( sprintf( '!!! For subscription %d: unable to repair status, exception "%s"', $subscription->id, $e->getMessage() ) );
				$repair_status = false;
			}
		} else {
			WCS_Upgrade_Logger::add( sprintf( 'For subscription %d: no need to repair status, current status: %s; former status: %s.', $subscription->id, $subscription->get_status(), $former_order_item_meta['_wcs_migrated_subscription_status'][0] ) );
			$repair_status = false;
		}
		return $repair_status;
	}

	/**
	 * There was a bug in the WCS_Upgrade_2_0::add_line_tax_data() method in Subscriptions 2.0.0 and 2.0.1 which
	 * prevented recurring line tax data from being copied correctly to newly created subscriptions. This bug was
	 * fixed in 2.0.2, so we can now use that method to make sure line tax data is set correctly. But to do that,
	 * we first need to massage some of the deprecated line item meta to use the original meta keys.
	 *
	 * @param  int $subscription_line_item_id ID of the new subscription line item
	 * @param  int $old_order_item_id ID of the old order line item
	 * @param  array $old_order_item The old line item
	 * @return bool|int the meta ID of the newly added '_line_tax_data' meta data row, or false if no line tax data was added.
	 */
	protected static function maybe_repair_line_tax_data( $subscription_line_item_id, $old_order_item_id, $old_order_item ) {

		// we need item meta in the old format so that we can use the (now fixed) WCS_Upgrade_2_0::add_line_tax_data() method and save duplicating its code
		$old_order_item['item_meta']['_recurring_line_total']        = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_total'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_total']: 0;
		$old_order_item['item_meta']['_recurring_line_tax']          = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax'] : 0;
		$old_order_item['item_meta']['_recurring_line_subtotal_tax'] = isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_subtotal_tax'] ) ? $old_order_item['item_meta']['_wcs_migrated_recurring_line_subtotal_tax'] : 0;

		if ( isset( $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax_data'] ) ) {
			$old_order_item['item_meta']['_recurring_line_tax_data'] = $old_order_item['item_meta']['_wcs_migrated_recurring_line_tax_data'];
		}

		return WCS_Upgrade_2_0::add_line_tax_data( $subscription_line_item_id, $old_order_item_id, $old_order_item );
	}
}