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

@@ -0,0 +1,123 @@
<?php
/**
* Gateway Capabilities — Subscription auto-renew declaration
*
* Single source of truth for "can this payment gateway auto-debit a
* subscription renewal, or does it fall through to manual?"
*
* Storage:
* wp_option('woonoow_gateway_subscription_capabilities')
* shape: [ '<gateway_id>' => [ 'subscription_auto_renew' => bool, ... ], ... ]
*
* Defaults are explicit per gateway ID so the merchant sees a meaningful
* matrix out of the box. The defaults reflect the regulatory reality
* discussed in SUBSCRIPTION_MODULE_AUDIT.md §9.5:
* - Indonesian VA/QRIS/e-wallet gateways: false (no recurring)
* - Indonesian credit-card gateways: false (BI/PCI-DSS re-auth)
* - PayPal/Stripe/Dodo: true ONLY when the merchant has a working
* adapter that implements process_subscription_renewal_payment;
* we still default to true because the integration is the common
* case in WooNooW's target market.
*
* The default for any *unknown* gateway is `false` — the safe side.
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
class GatewayCapabilities
{
const OPTION_KEY = 'woonoow_gateway_subscription_capabilities';
/**
* Built-in safe defaults. Keyed by WooCommerce payment-gateway ID.
*
* Filter 'woonoow_gateway_subscription_capabilities' lets adapters
* and third-party code extend this list at boot time.
*/
public static function default_capabilities(): array
{
return [
// Global auto-debit-capable gateways
'paypal' => ['subscription_auto_renew' => true],
'stripe' => ['subscription_auto_renew' => true],
'stripe_cc' => ['subscription_auto_renew' => true],
'stripe_sepa' => ['subscription_auto_renew' => true],
'dodo' => ['subscription_auto_renew' => true],
// Indonesian manual-only gateways (VA/QRIS/e-wallet/CC re-auth)
'tripay' => ['subscription_auto_renew' => false],
'midtrans' => ['subscription_auto_renew' => false],
'xendit' => ['subscription_auto_renew' => false],
'doku' => ['subscription_auto_renew' => false],
'duitku' => ['subscription_auto_renew' => false],
// Cheques / offline / no auto-debit
'cheque' => ['subscription_auto_renew' => false],
'bacs' => ['subscription_auto_renew' => false],
'cod' => ['subscription_auto_renew' => false],
];
}
/**
* Read the merged capability map: defaults < stored < filter.
* Always returns a fully-populated array (missing keys default to false).
*/
public static function all(): array
{
$stored = get_option(self::OPTION_KEY, []);
if (!is_array($stored)) {
$stored = [];
}
$merged = array_merge(self::default_capabilities(), $stored);
$merged = (array) apply_filters('woonoow_gateway_subscription_capabilities', $merged);
return $merged;
}
/**
* Single-gateway capability lookup.
* Returns true ONLY if explicitly declared true. Anything else is false.
*/
public static function supports_auto_renew(string $gateway_id): bool
{
$gateway_id = sanitize_key($gateway_id);
if ($gateway_id === '') {
return false;
}
$caps = self::all();
if (!isset($caps[$gateway_id])) {
return false; // unknown gateway: safe default
}
return !empty($caps[$gateway_id]['subscription_auto_renew']);
}
/**
* Site-level kill switch. When true, EVERY gateway is treated as
* manual regardless of per-gateway capability.
*/
public static function force_manual(): bool
{
$settings = \WooNooW\Core\ModuleRegistry::get_settings('subscription');
return !empty($settings['force_manual_renewal']);
}
/**
* The single decision function the renewal flow should call.
* Combines: kill switch > gateway capability.
*/
public static function should_attempt_auto_renew(string $gateway_id): bool
{
if (self::force_manual()) {
return false;
}
return self::supports_auto_renew($gateway_id);
}
}

View File

@@ -63,6 +63,7 @@ class SubscriptionManager
payment_meta LONGTEXT,
cancel_reason TEXT DEFAULT NULL,
pause_count INT UNSIGNED DEFAULT 0,
paused_at DATETIME DEFAULT NULL,
failed_payment_count INT UNSIGNED DEFAULT 0,
reminder_sent_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -87,11 +88,50 @@ class SubscriptionManager
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql_subscriptions);
dbDelta($sql_orders);
// Runtime migration: add paused_at to existing installations.
// dbDelta does not add new columns to existing tables.
if ($wpdb->get_var("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table_subscriptions' AND COLUMN_NAME = 'paused_at'") == 0) {
$wpdb->query("ALTER TABLE $table_subscriptions ADD COLUMN paused_at DATETIME DEFAULT NULL AFTER pause_count");
}
}
/**
* Read a subscription meta key with variation-first, parent-fallback resolution.
*
* M1 — A variable product can have a "License 1-year" variation (period=year,
* length=1) and a "License 5-year" variation (period=year, length=5) living
* as siblings on the same parent product. The merchant authors those values
* per-variation. We must read the variation value first, then fall back to
* the parent if the variation didn't set it.
*
* An empty string and the literal `false` (post-meta "missing") are both
* treated as "not set". Only a real value returns.
*
* @param string $key Post meta key, e.g. '_woonoow_subscription_period'.
* @param int $variation_id Variation ID, or 0 if no variation.
* @param int $product_id Parent product ID.
* @param mixed $default Returned if neither variation nor parent has a value.
* @return mixed
*/
public static function get_subscription_meta($key, $variation_id, $product_id, $default = null)
{
if ($variation_id) {
$v = get_post_meta($variation_id, $key, true);
if ($v !== '' && $v !== false && $v !== null) {
return $v;
}
}
$p = get_post_meta($product_id, $key, true);
if ($p !== '' && $p !== false && $p !== null) {
return $p;
}
return $default;
}
/**
* Create subscription from order item
*
*
* @param \WC_Order $order
* @param \WC_Order_Item_Product $item
* @return int|false Subscription ID or false on failure
@@ -109,11 +149,13 @@ class SubscriptionManager
return false;
}
// Get subscription settings from product
$billing_period = get_post_meta($product_id, '_woonoow_subscription_period', true) ?: 'month';
$billing_interval = absint(get_post_meta($product_id, '_woonoow_subscription_interval', true)) ?: 1;
$trial_days = absint(get_post_meta($product_id, '_woonoow_subscription_trial_days', true));
$subscription_length = absint(get_post_meta($product_id, '_woonoow_subscription_length', true));
// M1 — Read subscription meta variation-first, then fall back to parent.
// Variation-level overrides let a merchant sell e.g. "License 1-year" and
// "License 5-year" as variations of one variable product.
$billing_period = self::get_subscription_meta('_woonoow_subscription_period', $variation_id, $product_id, 'month');
$billing_interval = absint(self::get_subscription_meta('_woonoow_subscription_interval', $variation_id, $product_id, 1));
$trial_days = absint(self::get_subscription_meta('_woonoow_subscription_trial_days', $variation_id, $product_id, 0));
$subscription_length = absint(self::get_subscription_meta('_woonoow_subscription_length', $variation_id, $product_id, 0));
// Calculate dates
$now = current_time('mysql');
@@ -285,26 +327,52 @@ class SubscriptionManager
$where = "WHERE 1=1";
$params = [];
$joins = "";
if ($args['status']) {
$where .= " AND status = %s";
$where .= " AND s.status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where .= " AND product_id = %d";
$where .= " AND s.product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where .= " AND user_id = %d";
$where .= " AND s.user_id = %d";
$params[] = $args['user_id'];
}
$order = "ORDER BY created_at DESC";
// M4 — Free-text search. The user types something; we match on:
// - numeric input → subscriptions.id exactly
// - any input → user_email / display_name / user_login LIKE
// The customer-facing word "search" maps to a JOIN on wp_users. We don't
// attempt to LIKE-match product name here because that would require a
// second JOIN on wp_posts and product name relevance is rarely what the
// admin types into this box — they want to find a specific customer's
// subscription.
$search = is_string($args['search']) ? trim($args['search']) : '';
if ($search !== '') {
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
if (ctype_digit($search)) {
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
$params[] = (int) $search;
$params[] = '%' . $wpdb->esc_like($search) . '%';
$params[] = '%' . $wpdb->esc_like($search) . '%';
} else {
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
$order = "ORDER BY s.created_at DESC";
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
$sql = "SELECT s.* FROM " . self::$table_subscriptions . " s $joins $where $order $limit";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
@@ -315,7 +383,10 @@ class SubscriptionManager
/**
* Count subscriptions
*
*
* M4 — supports the same `search` semantics as `get_all` so the pagination
* total matches the filtered result set.
*
* @param array $args
* @return int
*/
@@ -325,13 +396,31 @@ class SubscriptionManager
$where = "WHERE 1=1";
$params = [];
$joins = "";
if (!empty($args['status'])) {
$where .= " AND status = %s";
$where .= " AND s.status = %s";
$params[] = $args['status'];
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
$search = is_string($args['search']) ? trim($args['search']) : '';
if ($search !== '') {
$joins .= " LEFT JOIN {$wpdb->users} u ON u.ID = s.user_id";
if (ctype_digit($search)) {
$where .= " AND (s.id = %d OR u.user_email LIKE %s OR u.display_name LIKE %s)";
$params[] = (int) $search;
$params[] = '%' . $wpdb->esc_like($search) . '%';
$params[] = '%' . $wpdb->esc_like($search) . '%';
} else {
$where .= " AND (u.user_email LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s)";
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " s $joins $where";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
@@ -437,11 +526,12 @@ class SubscriptionManager
$updated = $wpdb->update(
self::$table_subscriptions,
[
'status' => 'on-hold',
'status' => 'on-hold',
'pause_count' => $subscription->pause_count + 1,
'paused_at' => current_time('mysql'),
],
['id' => $subscription_id],
['%s', '%d'],
['%s', '%d', '%s'],
['%d']
);
@@ -479,6 +569,8 @@ class SubscriptionManager
$subscription->billing_interval
);
$update_data['next_payment_date'] = $next_payment;
$update_data['paused_at'] = null;
$format[] = '%s';
$format[] = '%s';
}
@@ -505,11 +597,17 @@ class SubscriptionManager
*/
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*
* M2 — `$charge_now = true` is the admin "charge immediately" flag. It
* bypasses the per-gateway capability gate so the auto-debit attempt is
* made even on normally-manual gateways. Use this for the ad-hoc admin
* "charge now" button, not for cron-driven renewals.
*
* @param int $subscription_id
* @param bool $charge_now M2: bypass capability gate, never fall through to manual.
* @return bool|array
*/
public static function renew($subscription_id)
public static function renew($subscription_id, $charge_now = false)
{
global $wpdb;
@@ -518,13 +616,15 @@ class SubscriptionManager
return false;
}
// Check for existing pending renewal order to prevent duplicates
// Check for existing pending/awaiting/failed renewal order to prevent duplicates.
// A previously failed renewal must also short-circuit so the customer retries the same
// order instead of accumulating duplicate renewal orders on the subscription.
$existing_pending = $wpdb->get_row($wpdb->prepare(
"SELECT so.order_id FROM " . self::$table_subscription_orders . " so
JOIN {$wpdb->posts} p ON so.order_id = p.ID
WHERE so.subscription_id = %d
AND so.order_type = 'renewal'
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold')",
AND p.post_status IN ('wc-pending', 'pending', 'wc-on-hold', 'on-hold', 'wc-failed', 'failed')",
$subscription_id
));
@@ -542,7 +642,7 @@ class SubscriptionManager
// Process payment
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
$payment_result = self::process_renewal_payment($subscription, $renewal_order, $charge_now);
if ($payment_result === true) {
self::handle_renewal_success($subscription_id, $renewal_order);
@@ -596,12 +696,20 @@ class SubscriptionManager
return false;
}
// Add product
// Add product. H4 — price-sync policy: by default we honor the customer's
// stored recurring_amount (grandfather them at the original price). The merchant
// can flip `price_sync_on_renewal` to 'use_current_product_price' to always bill
// the latest price on every renewal.
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
if ($product) {
$settings = ModuleRegistry::get_settings('subscription');
$price_mode = $settings['price_sync_on_renewal'] ?? 'use_stored';
$line_total = ($price_mode === 'use_current_product_price' && $product->get_price() !== '')
? (float) $product->get_price()
: (float) $subscription->recurring_amount;
$renewal_order->add_product($product, 1, [
'total' => $subscription->recurring_amount,
'subtotal' => $subscription->recurring_amount,
'total' => $line_total,
'subtotal' => $line_total,
]);
}
@@ -630,19 +738,22 @@ class SubscriptionManager
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool
*/
/**
* Process payment for renewal order
*
* @param object $subscription
*
* M2 — `$force = true` is used by the admin "charge now" button. It
* bypasses the GatewayCapabilities gate: the admin has explicitly
* declared intent to charge, so we attempt the auto-debit path even on
* gateways that are normally manual-only. If the gateway does not
* implement `process_subscription_renewal_payment` and no external
* handler picks it up via filter, we fail loudly (return `false`) rather
* than silently creating a manual order — the admin expects an immediate
* charge, not a payment-link email.
*
* @param object $subscription
* @param \WC_Order $order
* @param bool $force M2: bypass capability gate; never fall through to manual.
* @return bool|string True if paid, false if failed, 'manual' if waiting
*/
private static function process_renewal_payment($subscription, $order)
private static function process_renewal_payment($subscription, $order, $force = false)
{
// Allow plugins to override payment processing completely
// Return true/false/'manual' to bypass default logic
@@ -663,14 +774,26 @@ class SubscriptionManager
$gateway = $gateways[$gateway_id];
// 1. Try Auto-Debit if supported
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
if (!is_wp_error($result) && $result) {
return true;
// 0. Per-gateway capability gate (§9 of the audit).
// If the gateway is not declared to support subscription auto-renew (or the kill
// switch is on), we skip the auto-debit attempt entirely and fall through to
// manual. The capability table is the merchant-visible source of truth — PHP
// introspection alone is no longer authoritative.
//
// M2 — `force` skips this gate. Admin has explicitly opted in to attempting the
// charge, so we ignore the capability declaration.
$capability_ok = $force || GatewayCapabilities::should_attempt_auto_renew($gateway_id);
if ($capability_ok) {
// 1. Try Auto-Debit if supported by the gateway implementation
if (method_exists($gateway, 'process_subscription_renewal_payment')) {
$result = $gateway->process_subscription_renewal_payment($order, $subscription);
if (!is_wp_error($result) && $result) {
return true;
}
// If explicit failure from auto-debit, return false (will trigger retry logic)
return false;
}
// If explicit failure from auto-debit, return false (will trigger retry logic)
return false;
}
// 2. Allow other plugins to handle auto-debit via filter (e.g. Stripe/PayPal adapters)
@@ -680,6 +803,15 @@ class SubscriptionManager
}
// 3. Fallback: Manual Payment
// M2 — In force mode, the admin said "charge now". If we got here, the gateway
// does not implement auto-debit and no external handler picked it up. Creating
// a manual order would silently contradict the admin's intent. Fail loudly so
// the admin sees the charge could not be processed.
if ($force) {
$order->update_status('failed', __('Admin "charge now" requested, but gateway does not support auto-debit', 'woonoow'));
return false;
}
// Set order to pending-payment
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));

View File

@@ -38,8 +38,15 @@ class SubscriptionModule
add_action('woocommerce_order_status_completed', [__CLASS__, 'maybe_create_subscription'], 10, 1);
add_action('woocommerce_order_status_processing', [__CLASS__, 'maybe_create_subscription'], 10, 1);
// Hook into order status change to handle manual renewal payments
// Hook into order status change to handle manual renewal payments and status sync
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
// Hook into entity deletions for cleanup and state sync
add_action('woocommerce_trash_order', [__CLASS__, 'on_order_deleted']);
add_action('woocommerce_delete_order', [__CLASS__, 'on_order_deleted']);
add_action('trashed_post', [__CLASS__, 'on_post_deleted']);
add_action('deleted_post', [__CLASS__, 'on_post_deleted']);
add_action('delete_user', [__CLASS__, 'on_user_deleted']);
// Modify add to cart button text for subscription products
add_filter('woocommerce_product_single_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
@@ -275,6 +282,14 @@ class SubscriptionModule
$product_id = $product->get_id();
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
// Guests cannot have subscriptions — create_from_order() rejects guest orders silently
// (H6). Show the standard add-to-cart text and let the regular checkout flow handle
// guest sign-up. The subscription will only be created if/when the customer converts
// to a user. We do NOT advertise a subscription capability the system cannot honor.
if (!is_user_logged_in()) {
return $text;
}
$settings = ModuleRegistry::get_settings('subscription');
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
}
@@ -532,7 +547,7 @@ class SubscriptionModule
}
/**
* Handle manual renewal payment completion
* Handle order status changes for both parent and renewal orders
*/
public static function on_order_status_changed($order_id, $old_status, $new_status)
{
@@ -540,24 +555,108 @@ class SubscriptionModule
return;
}
if (!in_array($new_status, ['processing', 'completed'])) {
return;
}
// Check if this is a subscription renewal order
global $wpdb;
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
$link = $wpdb->get_row($wpdb->prepare(
// Find if this order is linked to any subscription
$links = $wpdb->get_results($wpdb->prepare(
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
$order_id
));
if ($link && $link->order_type === 'renewal') {
$order = wc_get_order($order_id);
if ($order) {
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
if (empty($links)) {
return;
}
foreach ($links as $link) {
$subscription = SubscriptionManager::get($link->subscription_id);
if (!$subscription) continue;
if ($link->order_type === 'renewal') {
if (in_array($new_status, ['processing', 'completed'])) {
$order = wc_get_order($order_id);
if ($order) {
SubscriptionManager::handle_renewal_success($link->subscription_id, $order);
}
} elseif ($new_status === 'failed') {
SubscriptionManager::handle_renewal_failure($link->subscription_id);
} elseif ($new_status === 'cancelled') {
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'renewal_order_cancelled');
} elseif ($new_status === 'refunded') {
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'renewal_order_refunded');
}
} elseif ($link->order_type === 'parent') {
if (in_array($new_status, ['refunded', 'cancelled'])) {
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'parent_order_' . $new_status);
} elseif ($new_status === 'on-hold') {
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'parent_order_on_hold');
}
}
}
}
/**
* Handle order trashing/deletion
*/
public static function on_order_deleted($order_id)
{
global $wpdb;
$table_orders = $wpdb->prefix . 'woonoow_subscription_orders';
$links = $wpdb->get_results($wpdb->prepare(
"SELECT subscription_id, order_type FROM $table_orders WHERE order_id = %d",
$order_id
));
foreach ($links as $link) {
if ($link->order_type === 'parent') {
SubscriptionManager::update_status($link->subscription_id, 'cancelled', 'parent_order_deleted');
} elseif ($link->order_type === 'renewal') {
SubscriptionManager::update_status($link->subscription_id, 'on-hold', 'renewal_order_deleted');
}
}
}
/**
* Handle product trashing/deletion
*/
public static function on_post_deleted($post_id)
{
if (get_post_type($post_id) !== 'product') {
return;
}
global $wpdb;
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
// Find active/on-hold subscriptions for this product
$affected = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $table_subs WHERE product_id = %d AND status IN ('active', 'on-hold', 'pending')",
$post_id
));
foreach ($affected as $sub_id) {
SubscriptionManager::update_status($sub_id, 'on-hold', 'product_deleted');
// Fire an action so merchants can hook in and get alerted
do_action('woonoow/subscription/product_deleted_alert', $sub_id, $post_id);
}
}
/**
* Handle user deletion
*/
public static function on_user_deleted($user_id)
{
global $wpdb;
$table_subs = $wpdb->prefix . 'woonoow_subscriptions';
$affected = $wpdb->get_col($wpdb->prepare(
"SELECT id FROM $table_subs WHERE user_id = %d AND status != 'cancelled'",
$user_id
));
foreach ($affected as $sub_id) {
SubscriptionManager::update_status($sub_id, 'cancelled', 'user_deleted');
}
}
}

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');
}
}
}
}