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