Subscription module: add gateway capability flow and UX fixes

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:38:42 +07:00
parent fec786daa6
commit df969b442d
15 changed files with 2375 additions and 138 deletions

View File

@@ -32,6 +32,16 @@ class SubscriptionScheduler
*/
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
*/
@@ -41,6 +51,8 @@ class SubscriptionScheduler
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();
@@ -65,6 +77,14 @@ class SubscriptionScheduler
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);
}
}
/**
@@ -75,6 +95,8 @@ class SubscriptionScheduler
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);
}
/**
@@ -240,7 +262,7 @@ class SubscriptionScheduler
/**
* Schedule a retry for failed payment
*
*
* @param int $subscription_id
*/
public static function schedule_retry($subscription_id)
@@ -260,4 +282,132 @@ class SubscriptionScheduler
);
}
}
/**
* 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');
}
}
}
}