finalizing subscription moduile, ready to test

This commit is contained in:
Dwindi Ramadhana
2026-01-29 11:54:42 +07:00
parent 6d2136d3b5
commit d80f34c8b9
34 changed files with 5619 additions and 468 deletions

View File

@@ -0,0 +1,893 @@
<?php
/**
* Subscription Manager
*
* Core business logic for subscription management
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class SubscriptionManager
{
/** @var string Subscriptions table name */
private static $table_subscriptions;
/** @var string Subscription orders table name */
private static $table_subscription_orders;
/**
* Initialize the manager
*/
public static function init()
{
global $wpdb;
self::$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
self::$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
}
/**
* Create database tables
*/
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
$sql_subscriptions = "CREATE TABLE $table_subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
variation_id BIGINT UNSIGNED DEFAULT NULL,
status ENUM('pending', 'active', 'on-hold', 'cancelled', 'expired', 'pending-cancel') DEFAULT 'pending',
billing_period ENUM('day', 'week', 'month', 'year') NOT NULL,
billing_interval INT UNSIGNED DEFAULT 1,
recurring_amount DECIMAL(12,4) DEFAULT 0,
start_date DATETIME NOT NULL,
trial_end_date DATETIME DEFAULT NULL,
next_payment_date DATETIME DEFAULT NULL,
end_date DATETIME DEFAULT NULL,
last_payment_date DATETIME DEFAULT NULL,
payment_method VARCHAR(100) DEFAULT NULL,
payment_meta LONGTEXT,
cancel_reason TEXT DEFAULT NULL,
pause_count INT UNSIGNED DEFAULT 0,
failed_payment_count INT UNSIGNED DEFAULT 0,
reminder_sent_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_order_id (order_id),
INDEX idx_product_id (product_id),
INDEX idx_status (status),
INDEX idx_next_payment (next_payment_date)
) $charset_collate;";
$sql_orders = "CREATE TABLE $table_subscription_orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
subscription_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
order_type ENUM('parent', 'renewal', 'switch', 'resubscribe') DEFAULT 'renewal',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_subscription (subscription_id),
INDEX idx_order (order_id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql_subscriptions);
dbDelta($sql_orders);
}
/**
* Create subscription from order item
*
* @param \WC_Order $order
* @param \WC_Order_Item_Product $item
* @return int|false Subscription ID or false on failure
*/
public static function create_from_order($order, $item)
{
global $wpdb;
$product_id = $item->get_product_id();
$variation_id = $item->get_variation_id();
$user_id = $order->get_user_id();
if (!$user_id) {
// Guest orders not supported for subscriptions
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));
// Calculate dates
$now = current_time('mysql');
$start_date = $now;
$trial_end_date = null;
if ($trial_days > 0) {
$trial_end_date = date('Y-m-d H:i:s', strtotime($now . " + $trial_days days"));
$next_payment_date = $trial_end_date;
} else {
$next_payment_date = self::calculate_next_payment_date($now, $billing_period, $billing_interval);
}
// Calculate end date if subscription has fixed length
$end_date = null;
if ($subscription_length > 0) {
$end_date = self::calculate_end_date($start_date, $billing_period, $billing_interval, $subscription_length);
}
// Get recurring amount (product price)
$product = $item->get_product();
$recurring_amount = $product ? $product->get_price() : $item->get_total();
// Get payment method
$payment_method = $order->get_payment_method();
$payment_meta = json_encode([
'method_title' => $order->get_payment_method_title(),
'customer_id' => $order->get_customer_id(),
]);
// Insert subscription
$inserted = $wpdb->insert(
self::$table_subscriptions,
[
'user_id' => $user_id,
'order_id' => $order->get_id(),
'product_id' => $product_id,
'variation_id' => $variation_id ?: null,
'status' => 'active',
'billing_period' => $billing_period,
'billing_interval' => $billing_interval,
'recurring_amount' => $recurring_amount,
'start_date' => $start_date,
'trial_end_date' => $trial_end_date,
'next_payment_date' => $next_payment_date,
'end_date' => $end_date,
'last_payment_date' => $now,
'payment_method' => $payment_method,
'payment_meta' => $payment_meta,
],
['%d', '%d', '%d', '%d', '%s', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
);
if (!$inserted) {
return false;
}
$subscription_id = $wpdb->insert_id;
// Link parent order to subscription
$wpdb->insert(
self::$table_subscription_orders,
[
'subscription_id' => $subscription_id,
'order_id' => $order->get_id(),
'order_type' => 'parent',
],
['%d', '%d', '%s']
);
// Trigger action
do_action('woonoow/subscription/created', $subscription_id, $order, $item);
return $subscription_id;
}
/**
* Get subscription by ID
*
* @param int $subscription_id
* @return object|null
*/
public static function get($subscription_id)
{
global $wpdb;
return $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . " WHERE id = %d",
$subscription_id
));
}
/**
* Get subscription by related order ID (parent or renewal)
*
* @param int $order_id
* @return object|null
*/
public static function get_by_order_id($order_id)
{
global $wpdb;
$table_subscriptions = $wpdb->prefix . 'woonoow_subscriptions';
$table_subscription_orders = $wpdb->prefix . 'woonoow_subscription_orders';
// Join subscriptions table to get full subscription data
return $wpdb->get_row($wpdb->prepare(
"SELECT s.*
FROM $table_subscriptions s
JOIN $table_subscription_orders so ON s.id = so.subscription_id
WHERE so.order_id = %d",
$order_id
));
}
/**
* Get subscriptions by user
*
* @param int $user_id
* @param array $args
* @return array
*/
public static function get_by_user($user_id, $args = [])
{
global $wpdb;
$defaults = [
'status' => null,
'limit' => 20,
'offset' => 0,
];
$args = wp_parse_args($args, $defaults);
$where = "WHERE user_id = %d";
$params = [$user_id];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = $wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . " $where ORDER BY created_at DESC LIMIT %d OFFSET %d",
array_merge($params, [$args['limit'], $args['offset']])
);
return $wpdb->get_results($sql);
}
/**
* Get all subscriptions (admin)
*
* @param array $args
* @return array
*/
public static function get_all($args = [])
{
global $wpdb;
$defaults = [
'status' => null,
'product_id' => null,
'user_id' => null,
'limit' => 20,
'offset' => 0,
'search' => null,
];
$args = wp_parse_args($args, $defaults);
$where = "WHERE 1=1";
$params = [];
if ($args['status']) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
if ($args['product_id']) {
$where .= " AND product_id = %d";
$params[] = $args['product_id'];
}
if ($args['user_id']) {
$where .= " AND user_id = %d";
$params[] = $args['user_id'];
}
$order = "ORDER BY created_at DESC";
$limit = "LIMIT " . intval($args['limit']) . " OFFSET " . intval($args['offset']);
$sql = "SELECT * FROM " . self::$table_subscriptions . " $where $order $limit";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
}
return $wpdb->get_results($sql);
}
/**
* Count subscriptions
*
* @param array $args
* @return int
*/
public static function count($args = [])
{
global $wpdb;
$where = "WHERE 1=1";
$params = [];
if (!empty($args['status'])) {
$where .= " AND status = %s";
$params[] = $args['status'];
}
$sql = "SELECT COUNT(*) FROM " . self::$table_subscriptions . " $where";
if (!empty($params)) {
$sql = $wpdb->prepare($sql, $params);
}
return (int) $wpdb->get_var($sql);
}
/**
* Update subscription status
*
* @param int $subscription_id
* @param string $status
* @param string|null $reason
* @return bool
*/
public static function update_status($subscription_id, $status, $reason = null)
{
global $wpdb;
$data = ['status' => $status];
$format = ['%s'];
if ($reason !== null) {
$data['cancel_reason'] = $reason;
$format[] = '%s';
}
$updated = $wpdb->update(
self::$table_subscriptions,
$data,
['id' => $subscription_id],
$format,
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/status_changed', $subscription_id, $status, $reason);
}
return $updated !== false;
}
/**
* Cancel subscription
*
* @param int $subscription_id
* @param string $reason
* @param bool $immediate Force immediate cancellation
* @return bool
*/
public static function cancel($subscription_id, $reason = '', $immediate = false)
{
$subscription = self::get($subscription_id);
if (!$subscription || in_array($subscription->status, ['cancelled', 'expired'])) {
return false;
}
// Default to pending-cancel if there's time left
$new_status = 'cancelled';
$now = current_time('mysql');
if (!$immediate && $subscription->next_payment_date && $subscription->next_payment_date > $now) {
$new_status = 'pending-cancel';
}
$success = self::update_status($subscription_id, $new_status, $reason);
if ($success) {
if ($new_status === 'pending-cancel') {
do_action('woonoow/subscription/pending_cancel', $subscription_id, $reason);
} else {
do_action('woonoow/subscription/cancelled', $subscription_id, $reason);
}
}
return $success;
}
/**
* Pause subscription
*
* @param int $subscription_id
* @return bool
*/
public static function pause($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || $subscription->status !== 'active') {
return false;
}
// Check max pause count
$settings = ModuleRegistry::get_settings('subscription');
$max_pause = $settings['max_pause_count'] ?? 3;
if ($max_pause > 0 && $subscription->pause_count >= $max_pause) {
return false;
}
$updated = $wpdb->update(
self::$table_subscriptions,
[
'status' => 'on-hold',
'pause_count' => $subscription->pause_count + 1,
],
['id' => $subscription_id],
['%s', '%d'],
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/paused', $subscription_id);
}
return $updated !== false;
}
/**
* Resume subscription
*
* @param int $subscription_id
* @return bool
*/
public static function resume($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || !in_array($subscription->status, ['on-hold', 'pending-cancel'])) {
return false;
}
$update_data = ['status' => 'active'];
$format = ['%s'];
// Only recalculate payment date if resuming from on-hold
if ($subscription->status === 'on-hold') {
// Recalculate next payment date from now
$next_payment = self::calculate_next_payment_date(
current_time('mysql'),
$subscription->billing_period,
$subscription->billing_interval
);
$update_data['next_payment_date'] = $next_payment;
$format[] = '%s';
}
$updated = $wpdb->update(
self::$table_subscriptions,
$update_data,
['id' => $subscription_id],
$format,
['%d']
);
if ($updated !== false) {
do_action('woonoow/subscription/resumed', $subscription_id);
}
return $updated !== false;
}
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*/
/**
* Process renewal for a subscription
*
* @param int $subscription_id
* @return bool
*/
public static function renew($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
if (!$subscription || !in_array($subscription->status, ['active', 'on-hold'])) {
return false;
}
// Check for existing pending renewal order to prevent duplicates
$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')",
$subscription_id
));
if ($existing_pending) {
return ['success' => true, 'order_id' => (int) $existing_pending->order_id, 'status' => 'existing'];
}
// Create renewal order
$renewal_order = self::create_renewal_order($subscription);
if (!$renewal_order) {
// Failed to create order
self::handle_renewal_failure($subscription_id);
return false;
}
// Process payment
// Result can be: true (paid), false (failed), or 'manual' (waiting for payment)
$payment_result = self::process_renewal_payment($subscription, $renewal_order);
if ($payment_result === true) {
self::handle_renewal_success($subscription_id, $renewal_order);
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'complete'];
} elseif ($payment_result === 'manual') {
// Manual payment required
// CHECK: Is this an early renewal? (Next payment date is in future)
$now = current_time('mysql');
$is_early_renewal = $subscription->next_payment_date && $subscription->next_payment_date > $now;
if ($is_early_renewal) {
// Early renewal: Keep active, just waiting for payment
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
}
// Normal/Overdue renewal: Set to on-hold
self::update_status($subscription_id, 'on-hold', 'awaiting_payment');
return ['success' => true, 'order_id' => $renewal_order->get_id(), 'status' => 'manual'];
} else {
// Auto-debit failed
self::handle_renewal_failure($subscription_id);
return false;
}
}
/**
* Create a renewal order
*
* @param object $subscription
* @return \WC_Order|false
*/
private static function create_renewal_order($subscription)
{
global $wpdb;
// Get original order
$parent_order = wc_get_order($subscription->order_id);
if (!$parent_order) {
return false;
}
// Create new order
$renewal_order = wc_create_order([
'customer_id' => $subscription->user_id,
'status' => 'pending',
'parent' => $subscription->order_id,
]);
if (is_wp_error($renewal_order)) {
return false;
}
// Add product
$product = wc_get_product($subscription->variation_id ?: $subscription->product_id);
if ($product) {
$renewal_order->add_product($product, 1, [
'total' => $subscription->recurring_amount,
'subtotal' => $subscription->recurring_amount,
]);
}
// Copy billing/shipping from parent
$renewal_order->set_address($parent_order->get_address('billing'), 'billing');
$renewal_order->set_address($parent_order->get_address('shipping'), 'shipping');
$renewal_order->set_payment_method($subscription->payment_method);
// Calculate totals
$renewal_order->calculate_totals();
$renewal_order->save();
// Link to subscription
$wpdb->insert(
self::$table_subscription_orders,
[
'subscription_id' => $subscription->id,
'order_id' => $renewal_order->get_id(),
'order_type' => 'renewal',
],
['%d', '%d', '%s']
);
return $renewal_order;
}
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool
*/
/**
* Process payment for renewal order
*
* @param object $subscription
* @param \WC_Order $order
* @return bool|string True if paid, false if failed, 'manual' if waiting
*/
private static function process_renewal_payment($subscription, $order)
{
// Allow plugins to override payment processing completely
// Return true/false/'manual' to bypass default logic
$pre = apply_filters('woonoow_pre_process_subscription_payment', null, $subscription, $order);
if ($pre !== null) {
return $pre;
}
// Get payment gateway
$gateways = WC()->payment_gateways()->get_available_payment_gateways();
$gateway_id = $subscription->payment_method;
if (!isset($gateways[$gateway_id])) {
// Payment method not available - treat as failure so user can fix
$order->update_status('failed', __('Payment method not available for renewal', 'woonoow'));
return false;
}
$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;
}
// 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)
$external_result = apply_filters('woonoow_process_subscription_payment', null, $gateway, $order, $subscription);
if ($external_result !== null) {
return $external_result ? true : false;
}
// 3. Fallback: Manual Payment
// Set order to pending-payment
$order->update_status('pending', __('Awaiting manual renewal payment', 'woonoow'));
// Send renewal payment email to customer
do_action('woonoow/subscription/renewal_payment_due', $subscription->id, $order);
return 'manual'; // Return special status
}
/**
* Handle successful renewal
*
* @param int $subscription_id
* @param \WC_Order $order
*/
public static function handle_renewal_success($subscription_id, $order)
{
global $wpdb;
$subscription = self::get($subscription_id);
// Calculate next payment date
// For early renewal, start from the current next_payment_date if it's in the future
// Otherwise start from now (for expired/overdue subscriptions)
$now = current_time('mysql');
$base_date = $now;
if ($subscription->next_payment_date && $subscription->next_payment_date > $now) {
$base_date = $subscription->next_payment_date;
}
$next_payment = self::calculate_next_payment_date(
$base_date,
$subscription->billing_period,
$subscription->billing_interval
);
// Check if subscription should end
if ($subscription->end_date && strtotime($next_payment) > strtotime($subscription->end_date)) {
$next_payment = null;
}
$wpdb->update(
self::$table_subscriptions,
[
'status' => 'active',
'next_payment_date' => $next_payment,
'last_payment_date' => current_time('mysql'),
'failed_payment_count' => 0,
],
['id' => $subscription_id],
['%s', '%s', '%s', '%d'],
['%d']
);
// Complete the order
$order->payment_complete();
do_action('woonoow/subscription/renewed', $subscription_id, $order);
}
/**
* Handle failed renewal
*
* @param int $subscription_id
*/
private static function handle_renewal_failure($subscription_id)
{
global $wpdb;
$subscription = self::get($subscription_id);
$new_failed_count = $subscription->failed_payment_count + 1;
// Get settings
$settings = ModuleRegistry::get_settings('subscription');
$max_attempts = $settings['expire_after_failed_attempts'] ?? 3;
if ($new_failed_count >= $max_attempts) {
// Mark as expired
$wpdb->update(
self::$table_subscriptions,
[
'status' => 'expired',
'failed_payment_count' => $new_failed_count,
],
['id' => $subscription_id],
['%s', '%d'],
['%d']
);
do_action('woonoow/subscription/expired', $subscription_id, 'payment_failed');
} else {
// Just increment failed count
$wpdb->update(
self::$table_subscriptions,
['failed_payment_count' => $new_failed_count],
['id' => $subscription_id],
['%d'],
['%d']
);
do_action('woonoow/subscription/renewal_failed', $subscription_id, $new_failed_count);
}
}
/**
* Calculate next payment date
*
* @param string $from_date
* @param string $period
* @param int $interval
* @return string
*/
public static function calculate_next_payment_date($from_date, $period, $interval = 1)
{
$interval = max(1, $interval);
switch ($period) {
case 'day':
$modifier = "+ $interval days";
break;
case 'week':
$modifier = "+ $interval weeks";
break;
case 'month':
$modifier = "+ $interval months";
break;
case 'year':
$modifier = "+ $interval years";
break;
default:
$modifier = "+ 1 month";
}
return date('Y-m-d H:i:s', strtotime($from_date . ' ' . $modifier));
}
/**
* Calculate subscription end date
*
* @param string $start_date
* @param string $period
* @param int $interval
* @param int $length
* @return string
*/
private static function calculate_end_date($start_date, $period, $interval, $length)
{
$total_periods = $interval * $length;
switch ($period) {
case 'day':
$modifier = "+ $total_periods days";
break;
case 'week':
$modifier = "+ $total_periods weeks";
break;
case 'month':
$modifier = "+ $total_periods months";
break;
case 'year':
$modifier = "+ $total_periods years";
break;
default:
$modifier = "+ $total_periods months";
}
return date('Y-m-d H:i:s', strtotime($start_date . ' ' . $modifier));
}
/**
* Get subscriptions due for renewal
*
* @return array
*/
public static function get_due_renewals()
{
global $wpdb;
$now = current_time('mysql');
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM " . self::$table_subscriptions . "
WHERE status = 'active'
AND next_payment_date IS NOT NULL
AND next_payment_date <= %s
ORDER BY next_payment_date ASC",
$now
));
}
/**
* Get subscription orders
*
* @param int $subscription_id
* @return array
*/
public static function get_orders($subscription_id)
{
global $wpdb;
return $wpdb->get_results($wpdb->prepare(
"SELECT so.*, p.post_status as order_status
FROM " . self::$table_subscription_orders . " so
LEFT JOIN {$wpdb->posts} p ON so.order_id = p.ID
WHERE so.subscription_id = %d
ORDER BY so.created_at DESC",
$subscription_id
));
}
}

View File

@@ -0,0 +1,563 @@
<?php
/**
* Subscription Module Bootstrap
*
* @package WooNooW\Modules\Subscription
*/
namespace WooNooW\Modules\Subscription;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\SubscriptionSettings;
class SubscriptionModule
{
/**
* Initialize the subscription module
*/
public static function init()
{
// Register settings schema
SubscriptionSettings::init();
// Initialize manager immediately since we're already in plugins_loaded
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_subscription_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_subscription_fields']);
// Hook into order completion to create subscriptions
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
add_action('woocommerce_order_status_changed', [__CLASS__, 'on_order_status_changed'], 10, 3);
// 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);
add_filter('woocommerce_product_add_to_cart_text', [__CLASS__, 'subscription_add_to_cart_text'], 10, 2);
// Register subscription notification events
add_filter('woonoow_notification_events_registry', [__CLASS__, 'register_notification_events']);
// Hook subscription lifecycle events to send notifications
add_action('woonoow/subscription/pending_cancel', [__CLASS__, 'on_pending_cancel'], 10, 2);
add_action('woonoow/subscription/cancelled', [__CLASS__, 'on_cancelled'], 10, 2);
add_action('woonoow/subscription/expired', [__CLASS__, 'on_expired'], 10, 2);
add_action('woonoow/subscription/paused', [__CLASS__, 'on_paused'], 10, 1);
add_action('woonoow/subscription/resumed', [__CLASS__, 'on_resumed'], 10, 1);
add_action('woonoow/subscription/renewal_failed', [__CLASS__, 'on_renewal_failed'], 10, 2);
add_action('woonoow/subscription/renewal_payment_due', [__CLASS__, 'on_renewal_payment_due'], 10, 2);
add_action('woonoow/subscription/renewal_reminder', [__CLASS__, 'on_renewal_reminder'], 10, 1);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('subscription')) {
// Ensure tables exist
self::ensure_tables();
SubscriptionManager::init();
SubscriptionScheduler::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_subscriptions';
// Check if table exists
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
SubscriptionManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id)
{
if ($module_id === 'subscription') {
SubscriptionManager::create_tables();
}
}
/**
* Add subscription fields to product edit page
*/
public static function add_product_subscription_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
echo '<div class="options_group show_if_simple show_if_variable">';
woocommerce_wp_checkbox([
'id' => '_woonoow_subscription_enabled',
'label' => __('Enable Subscription', 'woonoow'),
'description' => __('Enable recurring subscription billing for this product', 'woonoow'),
]);
echo '<div class="woonoow-subscription-options" style="display:none;">';
woocommerce_wp_select([
'id' => '_woonoow_subscription_period',
'label' => __('Billing Period', 'woonoow'),
'description' => __('How often to bill the customer', 'woonoow'),
'options' => [
'day' => __('Daily', 'woonoow'),
'week' => __('Weekly', 'woonoow'),
'month' => __('Monthly', 'woonoow'),
'year' => __('Yearly', 'woonoow'),
],
'value' => get_post_meta($post->ID, '_woonoow_subscription_period', true) ?: 'month',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_interval',
'label' => __('Billing Interval', 'woonoow'),
'description' => __('Bill every X periods (e.g., 2 = every 2 months)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_interval', true) ?: 1,
'custom_attributes' => [
'min' => '1',
'max' => '365',
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_trial_days',
'label' => __('Free Trial Days', 'woonoow'),
'description' => __('Number of free trial days before first billing (0 = no trial)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_trial_days', true) ?: 0,
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_signup_fee',
'label' => __('Sign-up Fee', 'woonoow') . ' (' . get_woocommerce_currency_symbol() . ')',
'description' => __('One-time fee charged on first subscription order', 'woonoow'),
'type' => 'text',
'value' => get_post_meta($post->ID, '_woonoow_subscription_signup_fee', true) ?: '',
'data_type' => 'price',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_subscription_length',
'label' => __('Subscription Length', 'woonoow'),
'description' => __('Number of billing periods (0 = unlimited/until cancelled)', 'woonoow'),
'type' => 'number',
'value' => get_post_meta($post->ID, '_woonoow_subscription_length', true) ?: 0,
'custom_attributes' => [
'min' => '0',
'step' => '1',
],
]);
echo '</div>'; // .woonoow-subscription-options
// Add inline script to show/hide options based on checkbox
?>
<script type="text/javascript">
jQuery(function($) {
function toggleSubscriptionOptions() {
if ($('#_woonoow_subscription_enabled').is(':checked')) {
$('.woonoow-subscription-options').show();
} else {
$('.woonoow-subscription-options').hide();
}
}
toggleSubscriptionOptions();
$('#_woonoow_subscription_enabled').on('change', toggleSubscriptionOptions);
});
</script>
<?php
echo '</div>'; // .options_group
}
/**
* Save subscription fields
*/
public static function save_product_subscription_fields($post_id)
{
$subscription_enabled = isset($_POST['_woonoow_subscription_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_subscription_enabled', $subscription_enabled);
if (isset($_POST['_woonoow_subscription_period'])) {
update_post_meta($post_id, '_woonoow_subscription_period', sanitize_text_field($_POST['_woonoow_subscription_period']));
}
if (isset($_POST['_woonoow_subscription_interval'])) {
update_post_meta($post_id, '_woonoow_subscription_interval', absint($_POST['_woonoow_subscription_interval']));
}
if (isset($_POST['_woonoow_subscription_trial_days'])) {
update_post_meta($post_id, '_woonoow_subscription_trial_days', absint($_POST['_woonoow_subscription_trial_days']));
}
if (isset($_POST['_woonoow_subscription_signup_fee'])) {
update_post_meta($post_id, '_woonoow_subscription_signup_fee', wc_format_decimal($_POST['_woonoow_subscription_signup_fee']));
}
if (isset($_POST['_woonoow_subscription_length'])) {
update_post_meta($post_id, '_woonoow_subscription_length', absint($_POST['_woonoow_subscription_length']));
}
}
/**
* Maybe create subscription from completed order
*/
public static function maybe_create_subscription($order_id)
{
if (!ModuleRegistry::is_enabled('subscription')) {
return;
}
$order = wc_get_order($order_id);
if (!$order) {
return;
}
// Check if subscription already created for this order
if ($order->get_meta('_woonoow_subscription_created')) {
return;
}
foreach ($order->get_items() as $item) {
$product_id = $item->get_product_id();
$variation_id = $item->get_variation_id();
// Check if product has subscription enabled
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) !== 'yes') {
continue;
}
// Create subscription for this product
SubscriptionManager::create_from_order($order, $item);
}
// Mark order as processed
$order->update_meta_data('_woonoow_subscription_created', 'yes');
$order->save();
}
/**
* Modify add to cart button text for subscription products
*/
public static function subscription_add_to_cart_text($text, $product)
{
if (!ModuleRegistry::is_enabled('subscription')) {
return $text;
}
$product_id = $product->get_id();
if (get_post_meta($product_id, '_woonoow_subscription_enabled', true) === 'yes') {
$settings = ModuleRegistry::get_settings('subscription');
return $settings['button_text_subscribe'] ?? __('Subscribe Now', 'woonoow');
}
return $text;
}
/**
* Register subscription notification events
*
* @param array $events Existing events
* @return array Updated events
*/
public static function register_notification_events($events)
{
// Customer notifications
$events['subscription_pending_cancel'] = [
'id' => 'subscription_pending_cancel',
'label' => __('Subscription Pending Cancellation', 'woonoow'),
'description' => __('When a subscription is scheduled for cancellation at period end', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_cancelled'] = [
'id' => 'subscription_cancelled',
'label' => __('Subscription Cancelled', 'woonoow'),
'description' => __('When a subscription is cancelled and access ends', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_expired'] = [
'id' => 'subscription_expired',
'label' => __('Subscription Expired', 'woonoow'),
'description' => __('When a subscription expires due to end date or failed payments', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_paused'] = [
'id' => 'subscription_paused',
'label' => __('Subscription Paused', 'woonoow'),
'description' => __('When a subscription is put on hold', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_resumed'] = [
'id' => 'subscription_resumed',
'label' => __('Subscription Resumed', 'woonoow'),
'description' => __('When a subscription is resumed from pause', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_failed'] = [
'id' => 'subscription_renewal_failed',
'label' => __('Subscription Renewal Failed', 'woonoow'),
'description' => __('When a renewal payment fails', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_payment_due'] = [
'id' => 'subscription_renewal_payment_due',
'label' => __('Subscription Renewal Payment Due', 'woonoow'),
'description' => __('When a manual payment is required for subscription renewal', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => array_merge(self::get_subscription_variables(), [
'{payment_link}' => __('Link to payment page', 'woonoow'),
]),
];
$events['subscription_renewal_reminder'] = [
'id' => 'subscription_renewal_reminder',
'label' => __('Subscription Renewal Reminder', 'woonoow'),
'description' => __('Reminder before subscription renewal', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'customer',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
// Staff notifications
$events['subscription_cancelled_admin'] = [
'id' => 'subscription_cancelled',
'label' => __('Subscription Cancelled', 'woonoow'),
'description' => __('When a customer cancels their subscription', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'staff',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
$events['subscription_renewal_failed_admin'] = [
'id' => 'subscription_renewal_failed',
'label' => __('Subscription Renewal Failed', 'woonoow'),
'description' => __('When a subscription renewal payment fails', 'woonoow'),
'category' => 'subscriptions',
'recipient_type' => 'staff',
'wc_email' => '',
'enabled' => true,
'variables' => self::get_subscription_variables(),
];
return $events;
}
/**
* Get subscription-specific template variables
*
* @return array
*/
private static function get_subscription_variables()
{
return [
'{subscription_id}' => __('Subscription ID', 'woonoow'),
'{subscription_status}' => __('Subscription status', 'woonoow'),
'{product_name}' => __('Product name', 'woonoow'),
'{billing_period}' => __('Billing period (e.g., monthly)', 'woonoow'),
'{recurring_amount}' => __('Recurring payment amount', 'woonoow'),
'{next_payment_date}' => __('Next payment date', 'woonoow'),
'{end_date}' => __('Subscription end date', 'woonoow'),
'{cancel_reason}' => __('Cancellation reason', 'woonoow'),
];
}
/**
* Handle pending cancellation notification
*/
public static function on_pending_cancel($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_pending_cancel', $subscription_id, $reason);
}
/**
* Handle cancellation notification
*/
public static function on_cancelled($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_cancelled', $subscription_id, $reason);
}
/**
* Handle expiration notification
*/
public static function on_expired($subscription_id, $reason = '')
{
self::send_subscription_notification('subscription_expired', $subscription_id, $reason);
}
/**
* Handle pause notification
*/
public static function on_paused($subscription_id)
{
self::send_subscription_notification('subscription_paused', $subscription_id);
}
/**
* Handle resume notification
*/
public static function on_resumed($subscription_id)
{
self::send_subscription_notification('subscription_resumed', $subscription_id);
}
/**
* Handle renewal failed notification
*/
public static function on_renewal_failed($subscription_id, $failed_count)
{
self::send_subscription_notification('subscription_renewal_failed', $subscription_id, '', $failed_count);
}
/**
* Handle renewal payment due notification
*/
public static function on_renewal_payment_due($subscription_id, $order = null)
{
$payment_link = '';
if ($order && is_a($order, 'WC_Order')) {
$payment_link = $order->get_checkout_payment_url();
}
self::send_subscription_notification('subscription_renewal_payment_due', $subscription_id, '', 0, ['payment_link' => $payment_link]);
}
/**
* Handle renewal reminder notification
*/
public static function on_renewal_reminder($subscription)
{
if (!$subscription || !isset($subscription->id)) {
return;
}
self::send_subscription_notification('subscription_renewal_reminder', $subscription->id);
}
/**
* Send subscription notification
*
* @param string $event_id Event ID
* @param int $subscription_id Subscription ID
* @param string $reason Optional reason
* @param int $failed_count Optional failed payment count
* @param array $extra_data Optional extra data variables
*/
private static function send_subscription_notification($event_id, $subscription_id, $reason = '', $failed_count = 0, $extra_data = [])
{
$subscription = SubscriptionManager::get($subscription_id);
if (!$subscription) {
return;
}
$user = get_user_by('id', $subscription->user_id);
$product = wc_get_product($subscription->product_id);
$data = [
'subscription' => $subscription,
'customer' => $user,
'product' => $product,
'reason' => $reason,
'failed_count' => $failed_count,
'payment_link' => $extra_data['payment_link'] ?? '',
];
// Send via NotificationManager
if (class_exists('\\WooNooW\\Core\\Notifications\\NotificationManager')) {
\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data);
}
}
/**
* Handle manual renewal payment completion
*/
public static function on_order_status_changed($order_id, $old_status, $new_status)
{
if (!ModuleRegistry::is_enabled('subscription')) {
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(
"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);
}
}
}
}

View File

@@ -0,0 +1,263 @@
<?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';
/**
* 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']);
// 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);
}
}
/**
* 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);
}
/**
* 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']
);
}
}
}