Subscription module: add gateway capability flow and UX fixes
This commit is contained in:
@@ -288,6 +288,12 @@ class CheckoutController
|
||||
'next_payment_date' => $sub->next_payment_date,
|
||||
'end_date' => $sub->end_date,
|
||||
'recurring_amount' => (float) $sub->recurring_amount,
|
||||
// §9 — Renewal messaging. The order-pay page can choose between
|
||||
// "auto-renew enabled" and "manual renewal only" copy.
|
||||
'payment_method' => $sub->payment_method,
|
||||
'gateway_supports_auto_renew' => !empty($sub->payment_method)
|
||||
? \WooNooW\Modules\Subscription\GatewayCapabilities::should_attempt_auto_renew($sub->payment_method)
|
||||
: false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -324,7 +330,9 @@ class CheckoutController
|
||||
}
|
||||
|
||||
// Create order
|
||||
$order = wc_create_order();
|
||||
$order = wc_create_order([
|
||||
'created_via' => 'checkout'
|
||||
]);
|
||||
if (is_wp_error($order)) {
|
||||
return ['error' => $order->get_error_message()];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use WP_REST_Response;
|
||||
use WP_Error;
|
||||
use WooNooW\Core\ModuleRegistry;
|
||||
use WooNooW\Modules\Subscription\SubscriptionManager;
|
||||
use WooNooW\Modules\Subscription\GatewayCapabilities;
|
||||
|
||||
class SubscriptionsController
|
||||
{
|
||||
@@ -40,6 +41,17 @@ class SubscriptionsController
|
||||
},
|
||||
]);
|
||||
|
||||
// M3 — Bulk operations. Body shape: { action: 'cancel' | 'export_csv', ids: number[] }.
|
||||
// For 'cancel' we return { ok: int, failed: [{id, error}] }. For 'export_csv' the
|
||||
// response is a text/csv body with Content-Disposition.
|
||||
register_rest_route('woonoow/v1', '/subscriptions/bulk', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'bulk_action'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_subscription'],
|
||||
@@ -136,6 +148,23 @@ class SubscriptionsController
|
||||
return is_user_logged_in();
|
||||
},
|
||||
]);
|
||||
|
||||
// §9 — Gateway capability matrix (admin)
|
||||
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_gateway_capabilities'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('woonoow/v1', '/subscriptions/gateway-capabilities', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'update_gateway_capabilities'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('manage_woocommerce');
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,12 +176,16 @@ class SubscriptionsController
|
||||
'status' => $request->get_param('status'),
|
||||
'product_id' => $request->get_param('product_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'search' => $request->get_param('search'),
|
||||
'limit' => $request->get_param('per_page') ?: 20,
|
||||
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 20),
|
||||
];
|
||||
|
||||
$subscriptions = SubscriptionManager::get_all($args);
|
||||
$total = SubscriptionManager::count(['status' => $args['status']]);
|
||||
$total = SubscriptionManager::count([
|
||||
'status' => $args['status'],
|
||||
'search' => $args['search'],
|
||||
]);
|
||||
|
||||
// Enrich with product and user info
|
||||
$enriched = [];
|
||||
@@ -244,16 +277,27 @@ class SubscriptionsController
|
||||
|
||||
/**
|
||||
* Renew subscription (admin - force immediate renewal)
|
||||
*
|
||||
* M2 — supports `?charge_now=true` to bypass the per-gateway capability
|
||||
* gate. With the flag, the auto-debit path is attempted even on gateways
|
||||
* that are normally manual-only; on failure the order is marked failed
|
||||
* (no manual fallback) so the admin can see the charge couldn't go
|
||||
* through.
|
||||
*/
|
||||
public static function renew_subscription(WP_REST_Request $request)
|
||||
{
|
||||
$result = SubscriptionManager::renew($request->get_param('id'));
|
||||
$charge_now = filter_var($request->get_param('charge_now'), FILTER_VALIDATE_BOOLEAN);
|
||||
$result = SubscriptionManager::renew($request->get_param('id'), $charge_now);
|
||||
|
||||
if (!$result) {
|
||||
return new WP_Error('renew_failed', __('Failed to process renewal', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(['success' => true, 'order_id' => $result['order_id']]);
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'order_id' => $result['order_id'],
|
||||
'status' => $result['status'] ?? 'complete',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +328,98 @@ class SubscriptionsController
|
||||
return new WP_REST_Response(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* M3 — Bulk action endpoint.
|
||||
*
|
||||
* Body: { action: 'cancel' | 'export_csv', ids: number[] }
|
||||
*
|
||||
* - 'cancel' returns JSON `{ ok: int, failed: [{id, error}] }`. Per-subscription
|
||||
* errors do not abort the batch — the admin sees the per-row outcome.
|
||||
* - 'export_csv' streams a CSV download. We don't use WP_REST_Response's
|
||||
* download flag because we want to set a custom filename.
|
||||
*
|
||||
* Hard cap of 500 ids per call to avoid runaway batches. A real implementation
|
||||
* would dispatch this via Action Scheduler; for now we run inline because
|
||||
* 500 cancels is <1s of DB writes.
|
||||
*/
|
||||
public static function bulk_action(WP_REST_Request $request)
|
||||
{
|
||||
$action = (string) $request->get_param('action');
|
||||
$ids = $request->get_param('ids');
|
||||
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
return new WP_Error('bad_request', __('ids must be a non-empty array', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
// Coerce to ints, drop non-numeric junk, dedupe.
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($i) { return $i > 0; })));
|
||||
if (empty($ids)) {
|
||||
return new WP_Error('bad_request', __('ids must contain at least one positive integer', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
if (count($ids) > 500) {
|
||||
return new WP_Error('batch_too_large', __('Maximum 500 ids per bulk request', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if ($action === 'cancel') {
|
||||
$ok = 0;
|
||||
$failed = [];
|
||||
foreach ($ids as $id) {
|
||||
$result = SubscriptionManager::cancel($id);
|
||||
if ($result === false || $result === null) {
|
||||
$failed[] = ['id' => $id, 'error' => __('Cancel returned false', 'woonoow')];
|
||||
} else {
|
||||
$ok++;
|
||||
}
|
||||
}
|
||||
return new WP_REST_Response(['ok' => $ok, 'failed' => $failed]);
|
||||
}
|
||||
|
||||
if ($action === 'export_csv') {
|
||||
$rows = [];
|
||||
foreach ($ids as $id) {
|
||||
$sub = SubscriptionManager::get($id);
|
||||
if (!$sub) {
|
||||
$rows[] = [
|
||||
'id' => $id, 'status' => 'missing', 'user_name' => '', 'user_email' => '',
|
||||
'product_name' => '', 'billing_period' => '', 'billing_interval' => '',
|
||||
'recurring_amount' => '', 'next_payment_date' => '', 'start_date' => '',
|
||||
'end_date' => '', 'payment_method' => '',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'id' => (int) $sub->id,
|
||||
'status' => (string) $sub->status,
|
||||
'user_name' => (string) ($sub->user_name ?? ''),
|
||||
'user_email' => (string) ($sub->user_email ?? ''),
|
||||
'product_name' => (string) ($sub->product_name ?? ''),
|
||||
'billing_period' => (string) $sub->billing_period,
|
||||
'billing_interval' => (int) $sub->billing_interval,
|
||||
'recurring_amount' => (string) $sub->recurring_amount,
|
||||
'next_payment_date' => (string) ($sub->next_payment_date ?? ''),
|
||||
'start_date' => (string) $sub->start_date,
|
||||
'end_date' => (string) ($sub->end_date ?? ''),
|
||||
'payment_method' => (string) ($sub->payment_method ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$filename = 'woonoow-subscriptions-' . gmdate('Ymd-His') . '.csv';
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
$out = fopen('php://output', 'w');
|
||||
if (!empty($rows)) {
|
||||
fputcsv($out, array_keys($rows[0]));
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, $r);
|
||||
}
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
return new WP_Error('unknown_action', __('Unknown bulk action', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer's subscriptions
|
||||
*/
|
||||
@@ -453,10 +589,31 @@ class SubscriptionsController
|
||||
|
||||
// Add computed fields
|
||||
$enriched['is_active'] = $subscription->status === 'active';
|
||||
$enriched['can_pause'] = $subscription->status === 'active';
|
||||
$enriched['can_resume'] = $subscription->status === 'on-hold';
|
||||
|
||||
// Surface pause-limit context to the client (H2). The server-side pause handler in
|
||||
// SubscriptionManager::pause() already enforces the limit; this just tells the UI how
|
||||
// many pauses remain so the button can be disabled with a tooltip before the customer
|
||||
// hits the wall and gets a generic 500.
|
||||
$settings = \WooNooW\Core\ModuleRegistry::get_settings('subscription');
|
||||
$max_pause_count = isset($settings['max_pause_count']) ? (int) $settings['max_pause_count'] : 3;
|
||||
$enriched['max_pause_count'] = $max_pause_count;
|
||||
$enriched['pauses_remaining'] = $max_pause_count > 0
|
||||
? max(0, $max_pause_count - (int) $subscription->pause_count)
|
||||
: null; // null = unlimited
|
||||
|
||||
// Whether this customer is actually allowed to pause, incorporating:
|
||||
// - feature toggle (allow_customer_pause setting)
|
||||
// - subscription status
|
||||
// - lifetime pause limit
|
||||
$allow_pause_feature = !empty($settings['allow_customer_pause']);
|
||||
$pause_limit_ok = ($max_pause_count <= 0) || ($subscription->pause_count < $max_pause_count);
|
||||
$enriched['can_pause'] = $subscription->status === 'active' && $allow_pause_feature && $pause_limit_ok;
|
||||
$enriched['can_resume'] = in_array($subscription->status, ['on-hold', 'pending-cancel']);
|
||||
$enriched['can_cancel'] = in_array($subscription->status, ['active', 'on-hold', 'pending']);
|
||||
|
||||
// Expose paused_at so the UI can show when the subscription was paused
|
||||
$enriched['paused_at'] = $subscription->paused_at ?? null;
|
||||
|
||||
// Format billing info
|
||||
$period_labels = [
|
||||
'day' => __('day', 'woonoow'),
|
||||
@@ -496,6 +653,110 @@ class SubscriptionsController
|
||||
|
||||
$enriched['payment_method_title'] = $payment_title;
|
||||
|
||||
// §9 — Tell the client whether the stored gateway is declared to support
|
||||
// subscription auto-renew. The renewal flow uses this for messaging and the
|
||||
// admin uses it for at-a-glance status.
|
||||
$enriched['gateway_supports_auto_renew'] = !empty($subscription->payment_method)
|
||||
? GatewayCapabilities::should_attempt_auto_renew($subscription->payment_method)
|
||||
: false;
|
||||
$enriched['gateway_force_manual'] = GatewayCapabilities::force_manual();
|
||||
|
||||
return $enriched;
|
||||
}
|
||||
|
||||
/**
|
||||
* §9 — List the merged gateway capability matrix for the admin UI.
|
||||
*
|
||||
* Returns a row per available WC payment gateway with:
|
||||
* id, title, description, enabled (site-enabled),
|
||||
* auto_renew (effective — capability table + kill switch),
|
||||
* override (the merchant-set override, or null if using default),
|
||||
* default (the built-in default for this gateway ID)
|
||||
*/
|
||||
public static function get_gateway_capabilities(WP_REST_Request $request)
|
||||
{
|
||||
if (!function_exists('WC')) {
|
||||
return new WP_Error('wc_missing', __('WooCommerce is not active.', 'woonoow'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$gateways = WC()->payment_gateways()->payment_gateways();
|
||||
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||
if (!is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
$defaults = GatewayCapabilities::default_capabilities();
|
||||
$kill_switch = GatewayCapabilities::force_manual();
|
||||
|
||||
$rows = [];
|
||||
foreach ($gateways as $id => $gateway) {
|
||||
$default = isset($defaults[$id]) ? (bool) $defaults[$id]['subscription_auto_renew'] : false;
|
||||
$override = array_key_exists($id, $stored) ? (bool) $stored[$id]['subscription_auto_renew'] : null;
|
||||
$effective = GatewayCapabilities::should_attempt_auto_renew($id);
|
||||
$rows[] = [
|
||||
'id' => $id,
|
||||
'title' => $gateway->get_title() ?: $gateway->method_title ?: $id,
|
||||
'description' => $gateway->get_description(),
|
||||
'enabled' => $gateway->enabled === 'yes',
|
||||
'default' => $default,
|
||||
'override' => $override,
|
||||
'auto_renew' => $effective,
|
||||
'forced_manual' => $kill_switch,
|
||||
];
|
||||
}
|
||||
|
||||
return new WP_REST_Response([
|
||||
'gateways' => array_values($rows),
|
||||
'kill_switch' => $kill_switch,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* §9 — Persist merchant overrides for the per-gateway capability table.
|
||||
*
|
||||
* Body shape: { overrides: { '<gateway_id>': bool | null, ... } }
|
||||
* - bool => explicit override (true = auto-renew, false = manual)
|
||||
* - null => clear override, fall back to default
|
||||
*
|
||||
* The kill switch is NOT set here — it lives in the standard module
|
||||
* settings under `force_manual_renewal` (use the generic settings endpoint).
|
||||
*/
|
||||
public static function update_gateway_capabilities(WP_REST_Request $request)
|
||||
{
|
||||
$body = $request->get_json_params();
|
||||
if (!is_array($body) || !isset($body['overrides']) || !is_array($body['overrides'])) {
|
||||
return new WP_Error('bad_request', __('overrides map is required.', 'woonoow'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$stored = get_option(GatewayCapabilities::OPTION_KEY, []);
|
||||
if (!is_array($stored)) {
|
||||
$stored = [];
|
||||
}
|
||||
|
||||
$defaults = GatewayCapabilities::default_capabilities();
|
||||
$valid_ids = $defaults;
|
||||
if (function_exists('WC')) {
|
||||
foreach (WC()->payment_gateways()->payment_gateways() as $id => $gw) {
|
||||
$valid_ids[$id] = ['subscription_auto_renew' => false];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($body['overrides'] as $id => $value) {
|
||||
$id = sanitize_key((string) $id);
|
||||
if ($id === '' || !array_key_exists($id, $valid_ids)) {
|
||||
continue; // unknown gateway — ignore
|
||||
}
|
||||
if ($value === null) {
|
||||
unset($stored[$id]);
|
||||
} else {
|
||||
$stored[$id] = ['subscription_auto_renew' => (bool) $value];
|
||||
}
|
||||
}
|
||||
|
||||
update_option(GatewayCapabilities::OPTION_KEY, $stored);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'overrides' => $stored,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
123
includes/Modules/Subscription/GatewayCapabilities.php
Normal file
123
includes/Modules/Subscription/GatewayCapabilities.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,32 @@ class SubscriptionSettings {
|
||||
'min' => 1,
|
||||
'max' => 14,
|
||||
],
|
||||
'force_manual_renewal' => [
|
||||
'type' => 'toggle',
|
||||
'label' => __('Force Manual Renewal (Override All Gateways)', 'woonoow'),
|
||||
'description' => __('Treat every gateway as manual-renewal only, regardless of the per-gateway capability table. Use as a kill switch when a regulator or incident requires no auto-debits.', 'woonoow'),
|
||||
'default' => false,
|
||||
],
|
||||
'price_sync_on_renewal' => [
|
||||
'type' => 'select',
|
||||
'label' => __('Renewal Price Sync', 'woonoow'),
|
||||
'description' => __('What price does a renewal order use when the product price has changed since the subscription started? "Use stored" grandfathers the customer at their original price (recommended). "Use current" re-syncs every renewal to the latest product price.', 'woonoow'),
|
||||
'options' => [
|
||||
'use_stored' => __('Use stored price (grandfather customer)', 'woonoow'),
|
||||
'use_current_product_price' => __('Use current product price', 'woonoow'),
|
||||
],
|
||||
'default' => 'use_stored',
|
||||
],
|
||||
'unpaid_renewal_max_age_days' => [
|
||||
'type' => 'number',
|
||||
'label' => __('Unpaid Renewal Auto-Cancel (days)', 'woonoow'),
|
||||
'description' => __('Days an unpaid manual renewal can stay on-hold before the subscription is auto-cancelled. The customer receives a daily reminder during this window. Set to 0 to disable auto-cancel (not recommended — abandoned-cart revenue leakage).', 'woonoow'),
|
||||
'default' => 7,
|
||||
'min' => 1,
|
||||
'max' => 90,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
return $schemas;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user