finalizing subscription moduile, ready to test
This commit is contained in:
893
includes/Modules/Subscription/SubscriptionManager.php
Normal file
893
includes/Modules/Subscription/SubscriptionManager.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
563
includes/Modules/Subscription/SubscriptionModule.php
Normal file
563
includes/Modules/Subscription/SubscriptionModule.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
263
includes/Modules/Subscription/SubscriptionScheduler.php
Normal file
263
includes/Modules/Subscription/SubscriptionScheduler.php
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user