414 lines
14 KiB
PHP
414 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Subscription Scheduler
|
|
*
|
|
* Handles cron jobs for subscription renewals and expirations
|
|
*
|
|
* @package WooNooW\Modules\Subscription
|
|
*/
|
|
|
|
namespace WooNooW\Modules\Subscription;
|
|
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
use WooNooW\Core\ModuleRegistry;
|
|
|
|
class SubscriptionScheduler
|
|
{
|
|
|
|
/**
|
|
* Cron hook for processing renewals
|
|
*/
|
|
const RENEWAL_HOOK = 'woonoow_process_subscription_renewals';
|
|
|
|
/**
|
|
* Cron hook for checking expired subscriptions
|
|
*/
|
|
const EXPIRY_HOOK = 'woonoow_check_expired_subscriptions';
|
|
|
|
/**
|
|
* Cron hook for sending renewal reminders
|
|
*/
|
|
const REMINDER_HOOK = 'woonoow_send_renewal_reminders';
|
|
|
|
/**
|
|
* Cron hook for retrying unpaid manual renewals (H5).
|
|
*/
|
|
const UNPAID_RETRY_HOOK = 'woonoow_retry_unpaid_renewals';
|
|
|
|
/**
|
|
* Cron hook for auto-resuming subscriptions paused beyond the allowed duration.
|
|
*/
|
|
const PAUSE_EXPIRY_HOOK = 'woonoow_check_pause_expirations';
|
|
|
|
/**
|
|
* Initialize the scheduler
|
|
*/
|
|
public static function init()
|
|
{
|
|
// Register cron handlers
|
|
add_action(self::RENEWAL_HOOK, [__CLASS__, 'process_renewals']);
|
|
add_action(self::EXPIRY_HOOK, [__CLASS__, 'check_expirations']);
|
|
add_action(self::REMINDER_HOOK, [__CLASS__, 'send_reminders']);
|
|
add_action(self::UNPAID_RETRY_HOOK, [__CLASS__, 'retry_unpaid_renewals']);
|
|
add_action(self::PAUSE_EXPIRY_HOOK, [__CLASS__, 'check_pause_expirations']);
|
|
|
|
// Schedule cron events if not already scheduled
|
|
self::schedule_events();
|
|
|
|
// Cleanup on plugin deactivation
|
|
register_deactivation_hook(WOONOOW_PLUGIN_FILE, [__CLASS__, 'unschedule_events']);
|
|
}
|
|
|
|
/**
|
|
* Schedule cron events
|
|
*/
|
|
public static function schedule_events()
|
|
{
|
|
if (!wp_next_scheduled(self::RENEWAL_HOOK)) {
|
|
wp_schedule_event(time(), 'hourly', self::RENEWAL_HOOK);
|
|
}
|
|
|
|
if (!wp_next_scheduled(self::EXPIRY_HOOK)) {
|
|
wp_schedule_event(time(), 'daily', self::EXPIRY_HOOK);
|
|
}
|
|
|
|
if (!wp_next_scheduled(self::REMINDER_HOOK)) {
|
|
wp_schedule_event(time(), 'daily', self::REMINDER_HOOK);
|
|
}
|
|
|
|
if (!wp_next_scheduled(self::UNPAID_RETRY_HOOK)) {
|
|
wp_schedule_event(time(), 'twicedaily', self::UNPAID_RETRY_HOOK);
|
|
}
|
|
|
|
if (!wp_next_scheduled(self::PAUSE_EXPIRY_HOOK)) {
|
|
wp_schedule_event(time(), 'daily', self::PAUSE_EXPIRY_HOOK);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unschedule cron events
|
|
*/
|
|
public static function unschedule_events()
|
|
{
|
|
wp_clear_scheduled_hook(self::RENEWAL_HOOK);
|
|
wp_clear_scheduled_hook(self::EXPIRY_HOOK);
|
|
wp_clear_scheduled_hook(self::REMINDER_HOOK);
|
|
wp_clear_scheduled_hook(self::UNPAID_RETRY_HOOK);
|
|
wp_clear_scheduled_hook(self::PAUSE_EXPIRY_HOOK);
|
|
}
|
|
|
|
/**
|
|
* Process due subscription renewals
|
|
*/
|
|
public static function process_renewals()
|
|
{
|
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
|
return;
|
|
}
|
|
|
|
$due_subscriptions = SubscriptionManager::get_due_renewals();
|
|
|
|
foreach ($due_subscriptions as $subscription) {
|
|
// Log renewal attempt
|
|
do_action('woonoow/subscription/renewal_processing', $subscription->id);
|
|
|
|
try {
|
|
$success = SubscriptionManager::renew($subscription->id);
|
|
|
|
if ($success) {
|
|
do_action('woonoow/subscription/renewal_completed', $subscription->id);
|
|
} else {
|
|
// Auto-debit failed (returns false), so schedule retry
|
|
// Note: 'manual' falls into a separate bucket in SubscriptionManager and returns true (handled)
|
|
self::schedule_retry($subscription->id);
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Log error
|
|
error_log('[WooNooW Subscription] Renewal failed for subscription #' . $subscription->id . ': ' . $e->getMessage());
|
|
do_action('woonoow/subscription/renewal_error', $subscription->id, $e);
|
|
|
|
// Also schedule retry on exception
|
|
self::schedule_retry($subscription->id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for expired subscriptions
|
|
*/
|
|
public static function check_expirations()
|
|
{
|
|
global $wpdb;
|
|
|
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
|
return;
|
|
}
|
|
|
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
|
$now = current_time('mysql');
|
|
|
|
// Find subscriptions that have passed their end date
|
|
$expired = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT id FROM $table
|
|
WHERE status = 'active'
|
|
AND end_date IS NOT NULL
|
|
AND end_date <= %s",
|
|
$now
|
|
));
|
|
|
|
foreach ($expired as $subscription) {
|
|
SubscriptionManager::update_status($subscription->id, 'expired');
|
|
do_action('woonoow/subscription/expired', $subscription->id, 'end_date_reached');
|
|
}
|
|
|
|
// Also check pending-cancel subscriptions that need to be finalized
|
|
$pending_cancel = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT id FROM $table
|
|
WHERE status = 'pending-cancel'
|
|
AND next_payment_date IS NOT NULL
|
|
AND next_payment_date <= %s",
|
|
$now
|
|
));
|
|
|
|
foreach ($pending_cancel as $subscription) {
|
|
SubscriptionManager::update_status($subscription->id, 'cancelled');
|
|
do_action('woonoow/subscription/cancelled', $subscription->id, 'pending_cancel_completed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send renewal reminder emails
|
|
*/
|
|
public static function send_reminders()
|
|
{
|
|
global $wpdb;
|
|
|
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
|
return;
|
|
}
|
|
|
|
// Check if reminders are enabled
|
|
$settings = ModuleRegistry::get_settings('subscription');
|
|
if (empty($settings['send_renewal_reminder'])) {
|
|
return;
|
|
}
|
|
|
|
$days_before = $settings['reminder_days_before'] ?? 3;
|
|
$reminder_date = date('Y-m-d H:i:s', strtotime("+$days_before days"));
|
|
$tomorrow = date('Y-m-d H:i:s', strtotime('+' . ($days_before + 1) . ' days'));
|
|
|
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
|
|
|
// Find subscriptions due for reminder (that haven't had reminder sent for this billing cycle)
|
|
$due_reminders = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT * FROM $table
|
|
WHERE status = 'active'
|
|
AND next_payment_date IS NOT NULL
|
|
AND next_payment_date >= %s
|
|
AND next_payment_date < %s
|
|
AND (reminder_sent_at IS NULL OR reminder_sent_at < last_payment_date OR (last_payment_date IS NULL AND reminder_sent_at < start_date))",
|
|
$reminder_date,
|
|
$tomorrow
|
|
));
|
|
|
|
foreach ($due_reminders as $subscription) {
|
|
// Trigger reminder email
|
|
do_action('woonoow/subscription/renewal_reminder', $subscription);
|
|
|
|
// Mark reminder as sent in database
|
|
$wpdb->update(
|
|
$table,
|
|
['reminder_sent_at' => current_time('mysql')],
|
|
['id' => $subscription->id],
|
|
['%s'],
|
|
['%d']
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get retry schedule for failed payments
|
|
*
|
|
* @param int $subscription_id
|
|
* @return string|null Next retry datetime or null if no more retries
|
|
*/
|
|
public static function get_next_retry_date($subscription_id)
|
|
{
|
|
$subscription = SubscriptionManager::get($subscription_id);
|
|
if (!$subscription) {
|
|
return null;
|
|
}
|
|
|
|
$settings = ModuleRegistry::get_settings('subscription');
|
|
|
|
if (empty($settings['renewal_retry_enabled'])) {
|
|
return null;
|
|
}
|
|
|
|
$retry_days_str = $settings['renewal_retry_days'] ?? '1,3,5';
|
|
$retry_days = array_map('intval', array_filter(explode(',', $retry_days_str)));
|
|
|
|
$failed_count = $subscription->failed_payment_count;
|
|
|
|
if ($failed_count >= count($retry_days)) {
|
|
return null; // No more retries
|
|
}
|
|
|
|
$days_to_add = $retry_days[$failed_count] ?? 1;
|
|
return date('Y-m-d H:i:s', strtotime("+$days_to_add days"));
|
|
}
|
|
|
|
/**
|
|
* Schedule a retry for failed payment
|
|
*
|
|
* @param int $subscription_id
|
|
*/
|
|
public static function schedule_retry($subscription_id)
|
|
{
|
|
global $wpdb;
|
|
|
|
$next_retry = self::get_next_retry_date($subscription_id);
|
|
|
|
if ($next_retry) {
|
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
|
$wpdb->update(
|
|
$table,
|
|
['next_payment_date' => $next_retry],
|
|
['id' => $subscription_id],
|
|
['%s'],
|
|
['%d']
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* H5 — Find subscriptions that are on-hold because a manual renewal order
|
|
* was created and never paid. Send a re-notification once per 24h. After
|
|
* `unpaid_renewal_max_age_days` (default 7), auto-cancel to prevent
|
|
* indefinite on-hold state. Auto-cancel is the last-resort safety net;
|
|
* the admin can resume manually at any time.
|
|
*/
|
|
public static function retry_unpaid_renewals()
|
|
{
|
|
global $wpdb;
|
|
|
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
|
return;
|
|
}
|
|
|
|
$settings = ModuleRegistry::get_settings('subscription');
|
|
$max_age_days = isset($settings['unpaid_renewal_max_age_days'])
|
|
? max(1, (int) $settings['unpaid_renewal_max_age_days'])
|
|
: 7;
|
|
|
|
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
|
|
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
|
|
$posts = $wpdb->posts;
|
|
$now = current_time('mysql');
|
|
$min_age = date('Y-m-d H:i:s', strtotime('-24 hours', strtotime($now)));
|
|
$max_age_cutoff = date('Y-m-d H:i:s', strtotime("-{$max_age_days} days", strtotime($now)));
|
|
$reminder_threshold = date('Y-m-d H:i:s', strtotime('-24 hours', strtotime($now)));
|
|
|
|
// Find (subscription, order) pairs where the renewal order is still unpaid
|
|
// and the order was created at least 24h ago. We rate-limit per-order by
|
|
// storing the last-notice timestamp in order meta.
|
|
$candidates = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT s.id AS subscription_id, o.order_id
|
|
FROM $table_subs s
|
|
JOIN $table_orders o ON o.subscription_id = s.id AND o.order_type = 'renewal'
|
|
JOIN $posts p ON p.ID = o.order_id
|
|
WHERE s.status = 'on-hold'
|
|
AND p.post_status IN ('wc-pending', 'pending', 'wc-failed', 'failed')
|
|
AND p.post_date <= %s
|
|
AND p.post_date >= %s",
|
|
$min_age,
|
|
$max_age_cutoff
|
|
));
|
|
|
|
foreach ($candidates as $row) {
|
|
$order = wc_get_order($row->order_id);
|
|
if (!$order) {
|
|
continue;
|
|
}
|
|
|
|
// Per-order rate limit: don't re-notify more than once per 24h.
|
|
$last_notice = (string) $order->get_meta('_woonoow_unpaid_notice_at', true);
|
|
if ($last_notice !== '' && strtotime($last_notice) > strtotime($reminder_threshold)) {
|
|
continue;
|
|
}
|
|
|
|
$subscription = SubscriptionManager::get($row->subscription_id);
|
|
if (!$subscription) {
|
|
continue;
|
|
}
|
|
|
|
// Re-fire the same notification that fired at first renewal. Email
|
|
// templates registered for `renewal_payment_due` will be sent.
|
|
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
|
|
|
|
$order->update_meta_data('_woonoow_unpaid_notice_at', $now);
|
|
$order->save();
|
|
}
|
|
|
|
// Auto-cancel: anything older than the cutoff that is still on-hold gets
|
|
// cancelled outright. The admin can override by resuming manually.
|
|
$auto_cancel = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT id FROM $table_subs
|
|
WHERE status = 'on-hold'
|
|
AND next_payment_date IS NOT NULL
|
|
AND next_payment_date <= %s",
|
|
$max_age_cutoff
|
|
));
|
|
foreach ($auto_cancel as $row) {
|
|
SubscriptionManager::update_status($row->id, 'cancelled', 'unpaid_renewal_timeout');
|
|
do_action('woonoow/subscription/cancelled', $row->id, 'unpaid_renewal_timeout');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-resume subscriptions that have been paused beyond the merchant-configured
|
|
* maximum pause duration. Runs daily.
|
|
*
|
|
* Setting: `max_pause_duration_days` (int, 0 = disabled/unlimited).
|
|
* When a subscription hits the limit it is automatically resumed, giving the
|
|
* customer a fresh billing cycle from now.
|
|
*/
|
|
public static function check_pause_expirations()
|
|
{
|
|
global $wpdb;
|
|
|
|
if (!ModuleRegistry::is_enabled('subscription')) {
|
|
return;
|
|
}
|
|
|
|
$settings = ModuleRegistry::get_settings('subscription');
|
|
$max_days = isset($settings['max_pause_duration_days']) ? (int) $settings['max_pause_duration_days'] : 0;
|
|
|
|
// 0 means unlimited — feature is disabled.
|
|
if ($max_days <= 0) {
|
|
return;
|
|
}
|
|
|
|
$table = $wpdb->prefix . 'woonoow_subscriptions';
|
|
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_days} days"));
|
|
|
|
// Find on-hold subscriptions paused longer than the allowed duration.
|
|
$expired_pauses = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT id FROM $table
|
|
WHERE status = 'on-hold'
|
|
AND paused_at IS NOT NULL
|
|
AND paused_at <= %s",
|
|
$cutoff
|
|
));
|
|
|
|
foreach ($expired_pauses as $row) {
|
|
$resumed = SubscriptionManager::resume($row->id);
|
|
if ($resumed) {
|
|
do_action('woonoow/subscription/auto_resumed', $row->id, 'max_pause_duration_exceeded');
|
|
}
|
|
}
|
|
}
|
|
}
|