Files
woocommerce-subscriptions/includes/core/class-wcs-action-scheduler-customer-notifications.php
2025-07-10 10:19:11 +00:00

550 lines
18 KiB
PHP

<?php
/**
* Scheduler for subscription notifications that uses the Action Scheduler
*
* @class WCS_Action_Scheduler_Customer_Notifications
* @version 7.7.0
* @package WooCommerce Subscriptions/Classes
* @category Class
*/
class WCS_Action_Scheduler_Customer_Notifications extends WCS_Scheduler {
/**
* @var int Time offset (in whole seconds) between the notification and the action it's notifying about.
*/
protected $time_offset;
/**
* @var array|string[] Notifications scheduled by this class.
*
* Just for reference.
*/
protected static $notification_actions = [
'woocommerce_scheduled_subscription_customer_notification_trial_expiration',
'woocommerce_scheduled_subscription_customer_notification_expiration',
'woocommerce_scheduled_subscription_customer_notification_renewal',
];
/**
* Name of Action Scheduler group used for customer notification actions.
*
* @var string
*/
protected static $notifications_as_group = 'wcs_customer_notifications';
/**
* Constructor.
*/
public function __construct() {
parent::__construct();
$setting_option = get_option(
WC_Subscriptions_Admin::$option_prefix . WC_Subscriptions_Email_Notifications::$offset_setting_string,
[
'number' => 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;
}
}