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