Files
woocommerce-subscriptions/includes/class-wcs-retry-manager.php
Prospress Inc e38fdb9d42 2.5.3
2019-04-05 09:35:18 +02:00

509 lines
19 KiB
PHP
Executable File

<?php
/**
* Manage the process of retrying a failed renewal payment that previously failed.
*
* @package WooCommerce Subscriptions
* @subpackage WCS_Retry_Manager
* @category Class
* @author Prospress
* @since 2.1
*/
class WCS_Retry_Manager {
/* the rules that control the retry schedule and behaviour of each retry */
protected static $retry_rules = array();
/* an instance of the class responsible for storing retry data */
protected static $store;
/* the setting ID for enabling/disabling the automatic retry system */
protected static $setting_id;
/* property to store the instance of WCS_Retry_Admin */
protected static $admin;
/**
* Background updater to process retries from old store.
*
* @var WCS_Retry_Background_Migrator
*/
protected static $background_migrator;
/**
* Our table maker instance.
*
* @var WCS_Table_Maker
*/
protected static $table_maker;
/**
* Attach callbacks and set the retry rules
*
* @codeCoverageIgnore
* @since 2.1
*/
public static function init() {
self::$setting_id = WC_Subscriptions_Admin::$option_prefix . '_enable_retry';
self::$admin = new WCS_Retry_Admin( self::$setting_id );
if ( self::is_retry_enabled() ) {
WCS_Retry_Email::init();
add_action( 'init', array( __CLASS__, 'init_store' ) );
add_filter( 'woocommerce_valid_order_statuses_for_payment', __CLASS__ . '::check_order_statuses_for_payment', 10, 2 );
add_filter( 'woocommerce_subscription_dates', __CLASS__ . '::add_retry_date_type' );
add_action( 'delete_post', __CLASS__ . '::maybe_cancel_retry_for_order' );
add_action( 'wp_trash_post', __CLASS__ . '::maybe_cancel_retry_for_order' );
add_action( 'woocommerce_subscription_status_updated', __CLASS__ . '::maybe_cancel_retry', 0, 3 );
add_action( 'woocommerce_subscriptions_retry_status_updated', __CLASS__ . '::maybe_delete_payment_retry_date', 0, 2 );
add_action( 'woocommerce_subscription_renewal_payment_failed', array( __CLASS__, 'maybe_apply_retry_rule' ), 10, 2 );
add_action( 'woocommerce_subscription_renewal_payment_failed', array( __CLASS__, 'maybe_reapply_last_retry_rule' ), 15, 2 );
add_action( 'woocommerce_scheduled_subscription_payment_retry', __CLASS__ . '::maybe_retry_payment' );
add_filter( 'woocommerce_subscriptions_is_failed_renewal_order', __CLASS__ . '::compare_order_and_retry_statuses', 10, 3 );
add_action( 'plugins_loaded', __CLASS__ . '::load_dependant_classes' );
add_action( 'woocommerce_subscriptions_before_upgrade', __CLASS__ . '::upgrade', 11, 2 );
if ( ! self::$table_maker ) {
self::$table_maker = new WCS_Retry_Table_Maker();
add_action( 'init', array( self::$table_maker, 'register_tables' ), 0 );
}
}
}
/**
* Adds any extra status that may be needed for a given order to check if it may
* need payment
*
* @param Array $statuses
* @param WC_Order $order
* @return array
* @since 2.2.1
*/
public static function check_order_statuses_for_payment( $statuses, $order ) {
$last_retry = self::store()->get_last_retry_for_order( wcs_get_objects_property( $order, 'id' ) );
if ( $last_retry ) {
$statuses[] = $last_retry->get_rule()->get_status_to_apply( 'order' );
$statuses = array_unique( $statuses );
}
return $statuses;
}
/**
* A helper function to check if the retry system has been enabled or not
*
* @since 2.1
*/
public static function is_retry_enabled() {
return (bool) apply_filters( 'wcs_is_retry_enabled', 'yes' == get_option( self::$setting_id, 'no' ) );
}
/**
* Add a renewal retry date type to Subscriptions date types
*
* @since 2.1
*/
public static function add_retry_date_type( $subscription_date_types ) {
$subscription_date_types = wcs_array_insert_after( 'next_payment', $subscription_date_types, 'payment_retry', _x( 'Renewal Payment Retry', 'table heading', 'woocommerce-subscriptions' ) );
return $subscription_date_types;
}
/**
* When a subscription's status is updated, if the new status isn't the expected retry subscription status, cancel the retry.
*
* @param object $subscription An instance of a WC_Subscription object
* @param string $new_status A valid subscription status
* @param string $old_status A valid subscription status
*/
public static function maybe_cancel_retry( $subscription, $new_status, $old_status ) {
if ( $subscription->get_date( 'payment_retry' ) > 0 ) {
$last_order = $subscription->get_last_order( 'all' );
$last_retry = ( $last_order ) ? self::store()->get_last_retry_for_order( wcs_get_objects_property( $last_order, 'id' ) ) : null;
if ( null !== $last_retry && 'cancelled' !== $last_retry->get_status() && null !== ( $last_retry_rule = $last_retry->get_rule() ) ) {
$retry_subscription_status = $last_retry_rule->get_status_to_apply( 'subscription' );
$applying_retry_rule = did_action( 'woocommerce_subscriptions_before_apply_retry_rule' ) !== did_action( 'woocommerce_subscriptions_after_apply_retry_rule' );
$retrying_payment = did_action( 'woocommerce_subscriptions_before_payment_retry' ) !== did_action( 'woocommerce_subscriptions_after_payment_retry' );
// If the new status isn't the expected retry subscription status and we aren't in the process of applying a retry rule or retrying payment, cancel the retry
if ( $new_status != $retry_subscription_status && ! $applying_retry_rule && ! $retrying_payment ) {
$last_retry->update_status( 'cancelled' );
$subscription->delete_date( 'payment_retry' );
}
}
}
}
/**
* When a (renewal) order is trashed or deleted, make sure its retries are also trashed/deleted.
*
* @param int $post_id
*/
public static function maybe_cancel_retry_for_order( $post_id ) {
if ( 'shop_order' == get_post_type( $post_id ) ) {
$last_retry = self::store()->get_last_retry_for_order( $post_id );
// Make sure the last retry is cancelled first so that it is unscheduled via self::maybe_delete_payment_retry_date()
if ( null !== $last_retry && 'cancelled' !== $last_retry->get_status() ) {
$last_retry->update_status( 'cancelled' );
}
foreach ( self::store()->get_retry_ids_for_order( $post_id ) as $retry_id ) {
self::store()->delete_retry( $retry_id );
}
}
}
/**
* When a retry's status is updated, if it's no longer pending or processing and it's the most recent retry,
* delete the retry date on the subscriptions related to the order
*
* @param object $retry An instance of a WCS_Retry object
* @param string $new_status A valid retry status
*/
public static function maybe_delete_payment_retry_date( $retry, $new_status ) {
if ( ! in_array( $new_status, array( 'pending', 'processing' ) ) ) {
$last_retry = self::store()->get_last_retry_for_order( $retry->get_order_id() );
if ( $retry->get_id() === $last_retry->get_id() ) {
foreach ( wcs_get_subscriptions_for_renewal_order( $retry->get_order_id() ) as $subscription ) {
$subscription->delete_date( 'payment_retry' );
}
}
}
}
/**
* When a payment fails, apply a retry rule, if one exists that applies to this failure.
*
* @param WC_Subscription $subscription The subscription on which the payment failed.
* @param WC_Order $last_order The order on which the payment failed (will be the most recent order on the subscription specified with the subscription param).
*
* @since 2.1
*/
public static function maybe_apply_retry_rule( $subscription, $last_order ) {
if ( $subscription->is_manual() || ! $subscription->payment_method_supports( 'subscription_date_changes' ) || ! self::is_scheduled_payment_attempt() ) {
return;
}
$retry_count = self::store()->get_retry_count_for_order( wcs_get_objects_property( $last_order, 'id' ) );
if ( self::rules()->has_rule( $retry_count, wcs_get_objects_property( $last_order, 'id' ) ) ) {
$retry_rule = self::rules()->get_rule( $retry_count, wcs_get_objects_property( $last_order, 'id' ) );
do_action( 'woocommerce_subscriptions_before_apply_retry_rule', $retry_rule, $last_order, $subscription );
$retry_id = self::store()->save( new WCS_Retry( array(
'status' => 'pending',
'order_id' => wcs_get_objects_property( $last_order, 'id' ),
'date_gmt' => gmdate( 'Y-m-d H:i:s', gmdate( 'U' ) + $retry_rule->get_retry_interval() ),
'rule_raw' => $retry_rule->get_raw_data(),
) ) );
foreach ( array( 'order' => $last_order, 'subscription' => $subscription ) as $object_key => $object ) {
$new_status = $retry_rule->get_status_to_apply( $object_key );
if ( '' !== $new_status && ! $object->has_status( $new_status ) ) {
$object->update_status( $new_status, _x( 'Retry rule applied:', 'used in order note as reason for why status changed', 'woocommerce-subscriptions' ) );
}
}
if ( $retry_rule->get_retry_interval() > 0 ) {
// by calling this after changing the status, this will also schedule the 'woocommerce_scheduled_subscription_payment_retry' action.
$subscription->update_dates( array( 'payment_retry' => gmdate( 'Y-m-d H:i:s', gmdate( 'U' ) + $retry_rule->get_retry_interval( $retry_count ) ) ) );
}
do_action( 'woocommerce_subscriptions_after_apply_retry_rule', $retry_rule, $last_order, $subscription );
}
}
/**
* (Maybe) reapply last retry rule if:
* - Payment is no-scheduled
* - $last_order contains a Retry
* - Retry contains a rule
*
* @param WC_Subscription $subscription The subscription on which the payment failed.
* @param WC_Order $last_order The order on which the payment failed (will be the most recent order on the subscription specified with the subscription param).
*
* @since 2.5.0
*/
public static function maybe_reapply_last_retry_rule( $subscription, $last_order ) {
// We're only interested in non-automatic payment attempts.
if ( self::is_scheduled_payment_attempt() ) {
return;
}
$last_retry = self::store()->get_last_retry_for_order( $last_order->get_id() );
if ( ! $last_retry || 'pending' !== $last_retry->get_status() || null === ( $last_retry_rule = $last_retry->get_rule() ) ) {
return;
}
foreach ( array( 'order' => $last_order, 'subscription' => $subscription ) as $object_type => $object ) {
$new_status = $last_retry_rule->get_status_to_apply( $object_type );
if ( '' !== $new_status && ! $object->has_status( $new_status ) ) {
$object->update_status( $new_status, _x( 'Retry rule reapplied:', 'used in order note as reason for why status changed', 'woocommerce-subscriptions' ) );
}
}
}
/**
* When a retry hook is triggered, check if the rules for that retry are still valid
* and if so, retry the payment.
*
* @param WC_Order|int The order on which the payment failed
* @since 2.1
*/
public static function maybe_retry_payment( $last_order ) {
if ( ! is_object( $last_order ) ) {
$last_order = wc_get_order( $last_order );
}
if ( false === $last_order ) {
return;
}
$subscriptions = wcs_get_subscriptions_for_renewal_order( $last_order );
$last_retry = self::store()->get_last_retry_for_order( wcs_get_objects_property( $last_order, 'id' ) );
// we only need to retry the payment if we have applied a retry rule for the order and it still needs payment
if ( null !== $last_retry && 'pending' === $last_retry->get_status() ) {
do_action( 'woocommerce_subscriptions_before_payment_retry', $last_retry, $last_order );
if ( $last_order->needs_payment() ) {
$last_retry->update_status( 'processing' );
$expected_order_status = $last_retry->get_rule()->get_status_to_apply( 'order' );
$valid_order_status = ( '' == $expected_order_status || $last_order->has_status( $expected_order_status ) );
$expected_subscription_status = $last_retry->get_rule()->get_status_to_apply( 'subscription' );
if ( '' == $expected_subscription_status ) {
$valid_subscription_status = true;
} else {
$valid_subscription_status = true;
foreach ( $subscriptions as $subscription ) {
if ( ! $subscription->has_status( $expected_subscription_status ) ) {
$valid_subscription_status = false;
break;
}
}
}
// if both statuses are still the same or there no special status was applied and the order still needs payment (i.e. there has been no manual intervention), trigger the payment hook
if ( $valid_order_status && $valid_subscription_status ) {
$unique_payment_methods = array();
$last_order->update_status( 'pending', _x( 'Subscription renewal payment retry:', 'used in order note as reason for why order status changed', 'woocommerce-subscriptions' ), true );
foreach ( $subscriptions as $subscription ) {
// Make sure the subscription is on hold in case something goes wrong while trying to process renewal and in case gateways expect the subscription to be on-hold, which is normally the case with a renewal payment
$subscription->update_status( 'on-hold', _x( 'Subscription renewal payment retry:', 'used in order note as reason for why subscription status changed', 'woocommerce-subscriptions' ) );
// Store a hash of the payment method and payment meta to determine if there's a single payment method being used.
$payment_meta_hash = md5( $subscription->get_payment_method() . json_encode( $subscription->get_payment_method_meta() ) );
$unique_payment_methods[ $payment_meta_hash ] = 1;
}
// Delete the payment method from the renewal order if the subscription has changed to manual renewal.
if ( wcs_order_contains_manual_subscription( $last_order, 'renewal' ) ) {
$last_order->set_payment_method( '' );
$last_order->add_order_note( 'Renewal payment retry skipped - related subscription has changed to manual renewal.' );
$last_order->save();
} elseif ( 1 < count( $unique_payment_methods ) ) {
// Throw an exception if there is more than 1 unique payment method.
// This could only occur under circumstances where batch processing renewals has grouped unlike subscriptions.
throw new Exception( __( 'Payment retry attempted on renewal order with multiple related subscriptions with no payment method in common.', 'woocommerce-subscriptions' ) );
} else {
// Before attempting to process payment, update the renewal order's payment method and meta to match the subscription's - in case it has changed.
wcs_copy_payment_method_to_order( $subscription, $last_order );
$last_order->save();
WC_Subscriptions_Payment_Gateways::trigger_gateway_renewal_payment_hook( $last_order );
// Now that we've attempted to process the payment, refresh the order
$last_order = wc_get_order( wcs_get_objects_property( $last_order, 'id' ) );
}
// if the order still needs payment, payment failed
if ( $last_order->needs_payment() ) {
$last_retry->update_status( 'failed' );
} else {
$last_retry->update_status( 'complete' );
}
} else {
// order or subscription statuses have been manually updated, so we'll cancel the retry
$last_retry->update_status( 'cancelled' );
}
} else {
// last order must have been paid for some other way, so we'll cancel the retry
$last_retry->update_status( 'cancelled' );
}
do_action( 'woocommerce_subscriptions_after_payment_retry', $last_retry, $last_order );
}
}
/**
* Determines if a renewal order and the last retry statuses are the same (used to determine if a payment method
* change is needed)
*
* @since 2.2.8
*/
public static function compare_order_and_retry_statuses( $is_failed_order, $order_id, $order_status ) {
$last_retry = self::store()->get_last_retry_for_order( $order_id );
if ( null !== $last_retry && $order_status === $last_retry->get_rule()->get_status_to_apply( 'order' ) ) {
$is_failed_order = true;
}
return $is_failed_order;
}
/**
* Loads/init our depended classes.
*
* @since 2.4
*/
public static function load_dependant_classes() {
if ( ! self::$background_migrator ) {
self::$background_migrator = new WCS_Retry_Background_Migrator( wc_get_logger() );
add_action( 'init', array( self::$background_migrator, 'init' ), 15 );
}
}
/**
* Runs our upgrade background scripts.
*
* @param string $new_version Version we're upgrading to.
* @param string $old_version Version we're upgrading from.
*
* @since 2.4
*/
public static function upgrade( $new_version, $old_version ) {
if ( '0' !== $old_version && version_compare( $old_version, '2.4', '<' ) ) {
self::$background_migrator->schedule_repair();
}
if ( version_compare( $new_version, '2.4.0', '>' ) ) {
WCS_Retry_Migrator::set_needs_migration();
}
}
/**
* Is `woocommerce_scheduled_subscription_payment` or `woocommerce_scheduled_subscription_payment_retry` current action?
*
* @return boolean
*
* @since 2.5.0
*/
protected static function is_scheduled_payment_attempt() {
/**
* Filter 'Is scheduled payment attempt?'
*
* @param boolean doing_action( 'woocommerce_scheduled_subscription_payment' ) || doing_action( 'woocommerce_scheduled_subscription_payment_retry' )
* @since 2.5.0
*/
return (bool) apply_filters( 'wcs_is_scheduled_payment_attempt', doing_action( 'woocommerce_scheduled_subscription_payment' ) || doing_action( 'woocommerce_scheduled_subscription_payment_retry' ) );
}
/**
* Access the object used to interface with the store.
*
* @return WCS_Retry_Store
* @since 2.4
*/
public static function store() {
if ( empty( self::$store ) ) {
if ( ! did_action( 'plugins_loaded' ) ) {
wcs_doing_it_wrong( __METHOD__, 'This method was called before the "plugins_loaded" hook. It applies a filter to the retry data store instantiated. For that to work, it should first be called after all plugins are loaded.', '2.4.1' );
}
$class = self::get_store_class();
self::$store = new $class();
}
return self::$store;
}
/**
* Get the class used for instantiating retry storage via self::store()
*
* @since 2.4
*/
protected static function get_store_class() {
$default_store_class = 'WCS_Retry_Database_Store';
if ( WCS_Retry_Migrator::needs_migration() ) {
$default_store_class = 'WCS_Retry_Hybrid_Store';
}
return apply_filters( 'wcs_retry_store_class', $default_store_class );
}
/**
* Setup and access the object used to interface with retry rules
*
* @since 2.1
*/
public static function rules() {
if ( empty( self::$retry_rules ) ) {
$class = self::get_rules_class();
self::$retry_rules = new $class();
}
return self::$retry_rules;
}
/**
* Get the class used for instantiating retry rules via self::rules()
*
* @since 2.1
*/
protected static function get_rules_class() {
return apply_filters( 'wcs_retry_rules_class', 'WCS_Retry_Rules' );
}
/**
* Initialise the store object used to interface with retry data.
*
* Hooked onto 'init' to allow third-parties to use their own data store
* and to ensure WordPress is fully loaded.
*
* @since 2.4.1
*/
public static function init_store() {
self::store()->init();
}
}