3, 'unit' => 'days', ] ); $this->set_time_offset( self::convert_offset_to_seconds( $setting_option ) ); add_action( 'woocommerce_before_subscription_object_save', [ $this, 'update_notifications' ], 10, 2 ); add_action( 'update_option_' . WC_Subscriptions_Admin::$option_prefix . WC_Subscriptions_Email_Notifications::$offset_setting_string, [ $this, 'set_time_offset_from_option' ], 5, 2 ); add_action( 'add_option_' . WC_Subscriptions_Admin::$option_prefix . WC_Subscriptions_Email_Notifications::$offset_setting_string, [ $this, 'set_time_offset_from_option' ], 5, 2 ); } /** * Check if the subscription period is too short to send a renewal notification. * * @param $subscription * * @return bool */ public static function is_subscription_period_too_short( $subscription ) { $period = $subscription->get_billing_period(); $interval = $subscription->get_billing_interval(); // By default, there are no shorter periods than days in WCS, so we ignore hours, minutes, etc. if ( $interval <= 2 && 'day' === $period ) { return true; } return false; } /** * Return time offset for notifications for given subscription. * * Generally, there is one offset for all subscriptions, but there's a filter. * * @param WC_Subscription $subscription * @param string $notification_type * * @return mixed|null */ public function get_time_offset( $subscription, $notification_type ) { /** * Offset between a subscription event and related notification. * * @since 7.7.0 * * @param int $time_offset In seconds * @param WC_Subscription $subscription * @param string $notification_type Can be 'trial_end', 'next_payment' or 'end'. * * @return int */ return apply_filters( 'woocommerce_subscription_customer_notification_time_offset', $this->time_offset, $subscription, $notification_type ); } /** * General time offset setter. * * @param int $time_offset In seconds * * @return void */ public function set_time_offset( $time_offset ) { $this->time_offset = $time_offset; } /** * Set the offset based on new value set in the option. * * @param $_ Unused parameter. * @param $new_option_value * * @return void */ public function set_time_offset_from_option( $_, $new_option_value ) { $this->time_offset = self::convert_offset_to_seconds( $new_option_value ); } /** * Calculate time offset in seconds from the settings array. * * @param array $offset Format: [ 'number' => 3, 'unit' => 'days' ] * * @return int */ protected static function convert_offset_to_seconds( $offset ) { $default_offset = 3 * DAY_IN_SECONDS; if ( ! isset( $offset['unit'] ) || ! isset( $offset['number'] ) ) { return $default_offset; } switch ( $offset['unit'] ) { case 'days': return ( $offset['number'] * DAY_IN_SECONDS ); case 'weeks': return ( $offset['number'] * WEEK_IN_SECONDS ); case 'months': return ( $offset['number'] * MONTH_IN_SECONDS ); case 'years': return ( $offset['number'] * YEAR_IN_SECONDS ); default: return $default_offset; } } /** * Maybe schedule a notification action for given subscription and timestamp. * * Will *not* schedule notification if: * - the notifications are globally disabled, * - the subscription isn't active/pending-cancel, * - the subscription's billing cycle is less than 3 days, * - there is already the same action scheduled for the same subscription and time. * * If only the time differs, the previous scheduled action will be unscheduled and a new one will replace it. * * @param WC_Subscription $subscription Subscription to schedule the action for. * @param string $action Action ID to schedule. * @param int $timestamp Time to schedule the notification for. * * @return void */ protected function maybe_schedule_notification( $subscription, $action, $timestamp ) { if ( ! WC_Subscriptions_Email_Notifications::notifications_globally_enabled() ) { return; } if ( ! $subscription->has_status( [ 'active', 'pending-cancel' ] ) ) { return; } if ( self::is_subscription_period_too_short( $subscription ) ) { return; } $action_args = self::get_action_args( $subscription ); $next_scheduled = as_next_scheduled_action( $action, $action_args, self::$notifications_as_group ); if ( $timestamp === $next_scheduled ) { return; } $this->unschedule_actions( $action, $action_args ); // Only reschedule if it's in the future if ( $timestamp <= time() ) { return; } as_schedule_single_action( $timestamp, $action, $action_args, self::$notifications_as_group ); } /** * Subtract time offset from given datetime based on the settings and subscription properties and return resulting timestamp. * * @param string $datetime * @param WC_Subscription $subscription * @param string $notification_type Can be 'trial_end', 'next_payment' or 'end'. * * @return int */ protected function subtract_time_offset( $datetime, $subscription, $notification_type ) { $dt = new DateTime( $datetime, new DateTimeZone( 'UTC' ) ); return $dt->getTimestamp() - $this->get_time_offset( $subscription, $notification_type ); } /** * Get the notification action name based on the date type. * * @param string $date_type * * @return string */ public static function get_action_from_date_type( $date_type ) { $action = ''; switch ( $date_type ) { case 'trial_end': $action = 'woocommerce_scheduled_subscription_customer_notification_trial_expiration'; break; case 'next_payment': $action = 'woocommerce_scheduled_subscription_customer_notification_renewal'; break; case 'end': $action = 'woocommerce_scheduled_subscription_customer_notification_expiration'; break; } return $action; } /** * Update notifications when subscription gets updated. * * To make batch processing easier, we need to handle the following use case: * 1. Subscription S1 gets updated. * 2. Notification config gets updated, a batch to fix all subscriptions is started and processes all subscriptions * with update time before the config got updated. * 3. Subscription S1 gets updated before it gets processed by the batch process. * * Thus, we update notifications for all subscriptions that are being updated after notification config change time * and which have their update time before that. * * As this gets called on Subscription save, the modification timestamp should be updated, too, and thus * the currently updated subscription no longer needs to be processed by the batch process. * * @param WC_Subscription $subscription * @param $subscription_data_store * * @return void */ public function update_notifications( $subscription, $subscription_data_store ) { if ( ! $subscription->has_status( 'active' ) && ! $subscription->has_status( 'pending-cancel' ) ) { return; } // Here, we need the 'old' update timestamp for comparison, so can't use get_date_modified() method. $subscription_update_time_raw = array_key_exists( 'date_modified', $subscription->get_data() ) ? $subscription->get_data()['date_modified'] : $subscription->get_date_created(); if ( ! $subscription_update_time_raw ) { $subscription_update_utc_timestamp = 0; } else { $subscription_update_time_raw->setTimezone( new DateTimeZone( 'UTC' ) ); $subscription_update_utc_timestamp = $subscription_update_time_raw->getTimestamp(); } $notification_settings_update_utc_timestamp = get_option( 'wcs_notification_settings_update_time', 0 ); if ( $subscription_update_utc_timestamp < $notification_settings_update_utc_timestamp ) { $this->schedule_all_notifications( $subscription ); } } /** * Schedule a notification with given type for given subscription. * * Date/time is determined automatically based on notification type, dates stored on the subscription, * and offset WCS_Action_Scheduler_Customer_Notifications::$time_offset. * * @param WC_Subscription $subscription * @param string $notification_type * * @return void */ protected function schedule_notification( $subscription, $notification_type ) { $action_name = self::get_action_from_date_type( $notification_type ); $event_date = $subscription->get_date( $notification_type ); $timestamp = $this->subtract_time_offset( $event_date, $subscription, $notification_type ); $this->maybe_schedule_notification( $subscription, $action_name, $timestamp ); } /** * Schedule all notifications for a subscription based on the dates defined on the subscription. * * Which notifications are needed for the subscription is determined by \WCS_Action_Scheduler_Customer_Notifications::get_valid_notifications. * * @param WC_Subscription $subscription * * @return void */ protected function schedule_all_notifications( $subscription ) { $valid_notifications = self::get_valid_notifications( $subscription ); $actual_notifications = $this->get_notifications( $subscription ); // Unschedule notifications that aren't valid for this subscription. $notifications_to_unschedule = array_diff( $actual_notifications, $valid_notifications ); foreach ( $notifications_to_unschedule as $notification_type ) { $this->unschedule_actions( self::get_action_from_date_type( $notification_type ), self::get_action_args( $subscription ) ); } // Schedule/check scheduling for valid notifications. foreach ( $valid_notifications as $notification_type ) { $this->schedule_notification( $subscription, $notification_type ); } } /** * Set which date types are affecting the notifications. * * Currently, only trial_end, end and next_payment are being used. * * @return void */ public function set_date_types_to_schedule() { $this->date_types_to_schedule = [ 'trial_end', 'next_payment', 'end', ]; } /** * Schedule notifications if the date has changed. * * @param object $subscription An instance of a WC_Subscription object * @param string $date_type Can be 'trial_end', 'next_payment', 'payment_retry', 'end', 'end_of_prepaid_term' or a custom date type * @param string $datetime A MySQL formatted date/time string in the GMT/UTC timezone. */ public function update_date( $subscription, $date_type, $datetime ) { if ( in_array( $date_type, $this->get_date_types_to_schedule(), true ) ) { $this->schedule_all_notifications( $subscription ); } } /** * Schedule notifications if the date has been deleted. * * @param WC_Subscription $subscription An instance of a WC_Subscription object * @param string $date_type Can be 'trial_end', 'next_payment', 'end', 'end_of_prepaid_term' or a custom date type */ public function delete_date( $subscription, $date_type ) { $action = $this->get_action_from_date_type( $date_type ); if ( $action ) { $this->unschedule_actions( $action, self::get_action_args( $subscription ) ); } $this->schedule_all_notifications( $subscription ); } /** * Unschedule all notifications for a subscription. * * @param object $subscription An instance of a WC_Subscription object * @param array $exceptions Array of notification actions to not unschedule * * @return void */ public function unschedule_all_notifications( $subscription = null, $exceptions = [] ) { foreach ( self::$notification_actions as $action ) { if ( in_array( $action, $exceptions, true ) ) { continue; } $this->unschedule_actions( $action, self::get_action_args( $subscription ) ); } } /** * When a subscription's status is updated, maybe schedule an event * * @param object $subscription An instance of a WC_Subscription object * @param string $new_status New subscription status * @param string $old_status Previous subscription status */ public function update_status( $subscription, $new_status, $old_status ) { switch ( $new_status ) { case 'active': // Schedule new notifications (will also unschedule unneeded ones). $this->schedule_all_notifications( $subscription ); break; case 'pending-cancel': // Unschedule all except expiration notification. $this->unschedule_all_notifications( $subscription, [ 'woocommerce_scheduled_subscription_customer_notification_expiration' ] ); break; case 'on-hold': case 'cancelled': case 'switched': case 'expired': case 'trash': $this->unschedule_all_notifications( $subscription ); break; } } /** * Get the args to set on the scheduled action. * * @param WC_Subscription|null $subscription An instance of WC_Subscription to get the hook for * * @return array Array of name => value pairs stored against the scheduled action. */ public static function get_action_args( $subscription ) { if ( ! $subscription ) { return []; } $action_args = [ 'subscription_id' => $subscription->get_id() ]; return $action_args; } /** * Get the args to set on the scheduled action. * * @param string $action_hook Name of event used as the hook for the scheduled action. * @param array $action_args Array of name => value pairs stored against the scheduled action. */ protected function unschedule_actions( $action_hook, $action_args = [] ) { as_unschedule_all_actions( $action_hook, $action_args, self::$notifications_as_group ); } /** * Returns true if given date for subscription is now or in the future. * * @param WC_Subscription $subscription Subscription whose date is examined. * @param string $date_type Date type to evaluate. * * @return bool */ protected static function is_date_in_the_future_or_now( $subscription, $date_type ) { $dt = new DateTime( $subscription->get_date( $date_type ), new DateTimeZone( 'UTC' ) ); $timestamp = $dt->getTimestamp(); return $timestamp >= time(); } /** * Return an array of notifications valid for given subscription based on the dates set on the subscription. * * This method doesn't take status into account. That's done in \WCS_Action_Scheduler_Customer_Notifications::update_status. * * Possible values in the array: 'end', 'trial_end', 'next_payment'. * * @param WC_Subscription $subscription * * @return array * @throws Exception */ public static function get_valid_notifications( $subscription ) { $notifications = []; if ( $subscription->get_date( 'end' ) && self::is_date_in_the_future_or_now( $subscription, 'end' ) ) { $notifications[] = 'end'; } if ( $subscription->get_date( 'trial_end' ) && self::is_date_in_the_future_or_now( $subscription, 'trial_end' ) ) { $notifications[] = 'trial_end'; } if ( $subscription->get_date( 'next_payment' ) ) { // Renewal notification is only valid after the trial ended. $trial_end = $subscription->get_date( 'trial_end' ); if ( $trial_end ) { $trial_end_dt = new DateTime( $trial_end, new DateTimeZone( 'UTC' ) ); $trial_end_timestamp = $trial_end_dt->getTimestamp(); if ( $trial_end_timestamp < time() && self::is_date_in_the_future_or_now( $subscription, 'next_payment' ) ) { $notifications[] = 'next_payment'; } } elseif ( self::is_date_in_the_future_or_now( $subscription, 'next_payment' ) ) { $notifications[] = 'next_payment'; } } /** * Filter: `woocommerce_subscription_valid_customer_notification_types`. * * Allows filtering the list of notification types that will be scheduled for a particular subscription. * * Default array format returned: * * array( * 'next_payment', // Exists if the subscription contains a next payment date in the future. * 'trial_end', // Exists if the subscription contains a trial end date in the future. * 'end' // Exists if the subscription contains an end date in the future. * ) * * @since 7.2.0 * * @param array $notifications Array of valid notification types. * @param WC_Subscription $subscription Subscription object. */ return (array) apply_filters( 'woocommerce_subscription_valid_customer_notification_types', $notifications, $subscription ); } /** * Returns a list of currently scheduled notifications for a subscription. * * Notifications are identified by the date type of the subscription. * I.e. possible values are: 'end', 'trial_end' and 'next_payment'. * * @param $subscription * * @return array */ public function get_notifications( $subscription ) { $notifications = []; $date_types = $this->get_date_types_to_schedule(); foreach ( $date_types as $date_type ) { $next_scheduled = as_next_scheduled_action( self::get_action_from_date_type( $date_type ), self::get_action_args( $subscription ), self::$notifications_as_group ); if ( $next_scheduled ) { $notifications[] = $date_type; } } return $notifications; } }