array( 'active', 'on-hold' ), 'gateway_missing' => array( 'active', 'on-hold' ), 'renewal_stuck' => array( 'active' ), 'manual_could_charge' => array( 'active' ), ); /** * Days ahead the renewal forecast covers. */ const FORECAST_DAYS = 30; /** * Registers the background hook. * * @return void */ public static function register() { add_action( Scheduler::HOOK_HEALTH_SCAN, array( __CLASS__, 'run_background' ) ); } /** * The last result, or an empty one. * * @return array */ public static function result() { $state = get_option( self::OPTION ); if ( ! is_array( $state ) ) { return array( 'status' => 'none', ); } unset( $state['cursor'] ); return $state; } /** * Starts a new scan, runs what fits in this request, and schedules the rest. * * @return array */ public static function start() { $state = array( 'status' => 'running', 'scanned' => 0, 'cursor' => array(), 'started_at' => current_time( 'mysql', true ), 'finished_at' => null, 'checks' => array( 'below_current_price' => array( 'count' => 0, 'monthly' => array(), ), 'gateway_missing' => array( 'count' => 0, 'gateways' => array(), ), 'renewal_stuck' => array( 'count' => 0 ), 'manual_could_charge' => array( 'count' => 0 ), ), // Renewals due in the next days (site dates) and recurring revenue per month, per currency. 'forecast' => array( 'from' => wp_date( 'Y-m-d' ), 'days' => array(), 'mrr' => array(), 'active' => 0, ), ); $state = self::advance( $state, self::sync_budget() ); if ( 'running' === $state['status'] ) { if ( Scheduler::available() ) { Scheduler::enqueue( Scheduler::HOOK_HEALTH_SCAN ); } else { $state = self::advance( $state, PHP_INT_MAX ); } } update_option( self::OPTION, $state, false ); return self::result(); } /** * Background continuation. * * @return void */ public static function run_background() { $state = get_option( self::OPTION ); if ( ! is_array( $state ) || 'running' !== $state['status'] ) { return; } $state = self::advance( $state, Scheduler::time_budget() ); update_option( self::OPTION, $state, false ); if ( 'running' === $state['status'] ) { Scheduler::enqueue( Scheduler::HOOK_HEALTH_SCAN ); } } /** * Scans from the stored cursor until the budget is spent. * * @param array $state State. * @param float $budget Seconds. * @return array */ private static function advance( array $state, $budget ) { $deadline = microtime( true ) + $budget; $cursor = $state['cursor']; $stopped = false; $query = new CohortQuery( CohortSpec::from_array( array( 'filters' => array( 'statuses' => array( 'active', 'on-hold' ) ) ) ) ); $query->iterate( static function ( \WC_Subscription $subscription ) use ( &$state, &$stopped, $deadline ) { ++$state['scanned']; self::inspect( $subscription, $state['checks'] ); self::forecast( $subscription, $state['forecast'] ); if ( microtime( true ) > $deadline ) { $stopped = true; return false; } return true; }, $cursor ); $state['cursor'] = $cursor; if ( ! $stopped && ! empty( $cursor['done'] ) ) { $state['status'] = 'complete'; $state['finished_at'] = current_time( 'mysql', true ); $state['cursor'] = array(); } return $state; } /** * Runs every check on one subscription. * * @param \WC_Subscription $subscription Subscription. * @param array $checks Tallies, by reference. * @return void */ private static function inspect( \WC_Subscription $subscription, array &$checks ) { $status = $subscription->get_status(); if ( in_array( $status, self::CHECKS['below_current_price'], true ) && BelowCurrentPriceFilter::check( $subscription ) ) { $currency = (string) $subscription->get_currency(); ++$checks['below_current_price']['count']; $checks['below_current_price']['monthly'][ $currency ] = Money::add( isset( $checks['below_current_price']['monthly'][ $currency ] ) ? $checks['below_current_price']['monthly'][ $currency ] : '0', PriceGap::monthly( $subscription ) ); } if ( in_array( $status, self::CHECKS['gateway_missing'], true ) && GatewayMissingFilter::check( $subscription ) ) { $gateway = (string) $subscription->get_payment_method(); ++$checks['gateway_missing']['count']; $checks['gateway_missing']['gateways'][ $gateway ] = ( isset( $checks['gateway_missing']['gateways'][ $gateway ] ) ? $checks['gateway_missing']['gateways'][ $gateway ] : 0 ) + 1; } if ( in_array( $status, self::CHECKS['renewal_stuck'], true ) && StuckRenewalFilter::check( $subscription ) ) { ++$checks['renewal_stuck']['count']; } if ( in_array( $status, self::CHECKS['manual_could_charge'], true ) && ManualCouldChargeFilter::check( $subscription ) ) { ++$checks['manual_could_charge']['count']; } } /** * Adds an active subscription to the forecast: its next renewal when it falls in the next * days (the amount the customer pays, tax included) and its revenue per month before tax. * * @param \WC_Subscription $subscription Subscription. * @param array $forecast Forecast, by reference. * @return void */ private static function forecast( \WC_Subscription $subscription, array &$forecast ) { if ( 'active' !== $subscription->get_status() ) { return; } ++$forecast['active']; $currency = (string) $subscription->get_currency(); $net = (float) $subscription->get_total() - (float) $subscription->get_total_tax(); $months = PriceGap::months_per_cycle( (string) $subscription->get_billing_period(), (int) $subscription->get_billing_interval() ); if ( $months > 0 ) { $forecast['mrr'][ $currency ] = Money::add( isset( $forecast['mrr'][ $currency ] ) ? $forecast['mrr'][ $currency ] : '0', $net / $months ); } // Every renewal inside the window: a weekly subscription renews four or five times in 30 days. // The renewals stop at the end date, when there is one. $next = (int) $subscription->get_time( 'next_payment', 'gmt' ); $until = time() + self::FORECAST_DAYS * DAY_IN_SECONDS; $end = (int) $subscription->get_time( 'end', 'gmt' ); $period = (string) $subscription->get_billing_period(); $interval = max( 1, (int) $subscription->get_billing_interval() ); for ( $i = 0; $next > time() && $next <= $until && ( ! $end || $next <= $end ) && $i < 62; $i++ ) { $day = wp_date( 'Y-m-d', $next ); if ( ! isset( $forecast['days'][ $day ][ $currency ] ) ) { $forecast['days'][ $day ][ $currency ] = array( 'count' => 0, 'amount' => '0', ); } ++$forecast['days'][ $day ][ $currency ]['count']; $forecast['days'][ $day ][ $currency ]['amount'] = Money::add( $forecast['days'][ $day ][ $currency ]['amount'], $subscription->get_total() ); $next = (int) wcs_add_time( $interval, $period, $next ); } } /** * Seconds the scan may run inside the request that starts it. * * @return float */ private static function sync_budget() { /** * Filters how long the health scan may run in the request that starts it. * * @param float $seconds Budget. */ return (float) apply_filters( 'recast_health_sync_budget', 4.0 ); } }