rule_type = 'product'; // Initialize rule configuration. $this->initialize_products( $rule_data ); $this->initialize_pricing( $rule_data ); // Debug initialization. $this->debug_log( 'Product Rule Initialized', array( 'rule_id' => $this->rule_id, 'apply_to_all' => $this->apply_to_all, 'product_ids' => $this->product_ids, 'has_quantity_ranges' => ! empty( $this->quantity_ranges ), 'quantity_ranges_count' => count( $this->quantity_ranges ), ) ); } /** * Initialize product configuration. * * @param array $rule_data Rule data. */ private function initialize_products( $rule_data ) { // Check if rule applies to all products. if ( isset( $this->adjustments['apply_to_all'] ) ) { $this->apply_to_all = (bool) $this->adjustments['apply_to_all']; } // Load specific product IDs (check multiple possible locations). $product_ids = array(); // Try different possible locations for product IDs. $possible_keys = array( 'product_ids', 'products' ); $possible_locations = array( $rule_data, $this->adjustments ); foreach ( $possible_locations as $location ) { foreach ( $possible_keys as $key ) { if ( isset( $location[ $key ] ) && ! empty( $location[ $key ] ) ) { if ( is_array( $location[ $key ] ) ) { $product_ids = $location[ $key ]; } else { $product_ids = explode( ',', $location[ $key ] ); } break 2; } } } $this->product_ids = array_map( 'intval', array_filter( $product_ids ) ); // If no specific products and not explicitly set to all, default to all. if ( empty( $this->product_ids ) && ! isset( $this->adjustments['apply_to_all'] ) ) { $this->apply_to_all = true; } // Load excluded products. if ( $this->apply_to_all && isset( $this->adjustments['excluded_products'] ) ) { if ( is_array( $this->adjustments['excluded_products'] ) ) { $this->excluded_products = $this->adjustments['excluded_products']; } else { $this->excluded_products = explode( ',', $this->adjustments['excluded_products'] ); } $this->excluded_products = array_map( 'intval', array_filter( $this->excluded_products ) ); } } /** * Initialize pricing configuration. * * @param array $rule_data Rule data. */ private function initialize_pricing( $rule_data ) { // First, try to load quantity ranges. $this->load_quantity_ranges( $rule_data ); // If no quantity ranges, load simple discount. if ( empty( $this->quantity_ranges ) ) { $this->load_simple_discount(); } // Debug output. $this->debug_log( 'Pricing initialized', array( 'has_quantity_ranges' => ! empty( $this->quantity_ranges ), 'quantity_ranges_count' => count( $this->quantity_ranges ), 'has_simple_discount' => null !== $this->simple_discount, 'simple_discount' => $this->simple_discount, ) ); } /** * Load quantity ranges from rule data. * * @param array $rule_data Rule data. */ private function load_quantity_ranges( $rule_data ) { $ranges = array(); // Check for quantity_ranges in adjustments. if ( isset( $this->adjustments['quantity_ranges'] ) ) { $ranges = $this->adjustments['quantity_ranges']; } elseif ( isset( $rule_data['quantity_ranges'] ) ) { // Check for quantity_ranges in rule_data directly. $ranges = $rule_data['quantity_ranges']; if ( is_string( $ranges ) ) { $ranges = json_decode( $ranges, true ); } } // Validate and clean up ranges. if ( is_array( $ranges ) && ! empty( $ranges ) ) { $this->quantity_ranges = $this->validate_quantity_ranges( $ranges ); } else { $this->quantity_ranges = array(); } } /** * Validate and clean quantity ranges. * * @param array $ranges Raw quantity ranges. * @return array Validated ranges. */ private function validate_quantity_ranges( $ranges ) { $validated = array(); foreach ( $ranges as $range ) { // Skip invalid ranges. if ( ! is_array( $range ) ) { continue; } // Must have min_quantity and discount_value. if ( ! isset( $range['min_quantity'] ) || ! isset( $range['discount_value'] ) ) { continue; } // Parse values. $min_qty = intval( $range['min_quantity'] ); $max_qty = null; // Parse max_quantity carefully. if ( isset( $range['max_quantity'] ) ) { $max_raw = $range['max_quantity']; // Handle different representations of "unlimited". if ( '' === $max_raw || null === $max_raw || '0' === $max_raw || 0 === $max_raw ) { $max_qty = null; // Unlimited. } else { $max_qty = intval( $max_raw ); // Validate max is greater than min. if ( $max_qty > 0 && $max_qty < $min_qty ) { continue; // Invalid range, skip it. } } } // Build validated range. $validated[] = array( 'min_quantity' => $min_qty, 'max_quantity' => $max_qty, 'discount_type' => isset( $range['discount_type'] ) ? $range['discount_type'] : 'percentage', 'discount_value' => floatval( $range['discount_value'] ), ); } // Sort by min_quantity ascending for consistent processing. usort( $validated, function ( $a, $b ) { return $a['min_quantity'] - $b['min_quantity']; } ); return $validated; } /** * Load simple discount when no quantity ranges are defined. */ private function load_simple_discount() { // Check for new array format first [{'type': 'percentage', 'value': 40}]. if ( is_array( $this->adjustments ) && isset( $this->adjustments[0] ) && is_array( $this->adjustments[0] ) ) { $first_adjustment = $this->adjustments[0]; if ( isset( $first_adjustment['type'] ) && isset( $first_adjustment['value'] ) ) { $this->simple_discount = array( 'type' => $first_adjustment['type'], 'value' => floatval( $first_adjustment['value'] ), ); $this->debug_log( 'Loaded simple discount from new format', $this->simple_discount ); return; } } // Check for old direct discount format in adjustments (backward compatibility). if ( isset( $this->adjustments['discount_type'] ) && isset( $this->adjustments['discount_value'] ) ) { $this->simple_discount = array( 'type' => $this->adjustments['discount_type'], 'value' => floatval( $this->adjustments['discount_value'] ), ); $this->debug_log( 'Loaded simple discount from old format', $this->simple_discount ); } elseif ( isset( $this->adjustments['default'] ) && is_array( $this->adjustments['default'] ) ) { // Check for default discount. if ( isset( $this->adjustments['default']['type'] ) && isset( $this->adjustments['default']['value'] ) ) { $this->simple_discount = array( 'type' => $this->adjustments['default']['type'], 'value' => floatval( $this->adjustments['default']['value'] ), ); $this->debug_log( 'Loaded simple discount from default', $this->simple_discount ); } } if ( null === $this->simple_discount ) { $this->debug_log( 'No simple discount found', array( 'adjustments' => $this->adjustments ) ); } } /** * Check if rule is applicable. * * @param array $context Context data for evaluation. * @return bool */ public function is_applicable( $context ) { // Check if rule is active. if ( 'active' !== $this->status ) { $this->debug_log( 'Rule not active' ); return false; } // Check date range. if ( ! $this->is_within_date_range() ) { $this->debug_log( 'Outside date range' ); return false; } // Check if product matches. if ( ! $this->product_matches( $context ) ) { $this->debug_log( 'Product does not match', array( 'product_id' => isset( $context['product_id'] ) ? $context['product_id'] : 'N/A', ) ); return false; } // Check conditions. if ( ! $this->check_conditions( $context ) ) { $this->debug_log( 'Conditions not met' ); return false; } $this->debug_log( 'Rule is applicable' ); return true; } /** * Check if product matches rule criteria. * * @param array $context Context data. * @return bool */ private function product_matches( $context ) { $product_id = isset( $context['product_id'] ) ? intval( $context['product_id'] ) : 0; $parent_id = isset( $context['parent_id'] ) ? intval( $context['parent_id'] ) : 0; $this->debug_log( 'Checking product match', array( 'product_id' => $product_id, 'parent_id' => $parent_id, 'apply_to_all' => $this->apply_to_all, 'specific_product_ids' => $this->product_ids, 'excluded_products' => $this->excluded_products, ) ); // If rule applies to all products. if ( $this->apply_to_all ) { // Check if product is excluded. if ( in_array( $product_id, $this->excluded_products, true ) ) { $this->debug_log( 'Product excluded from all-products rule', array( 'product_id' => $product_id ) ); return false; } // Check parent for variations. if ( $parent_id > 0 && in_array( $parent_id, $this->excluded_products, true ) ) { $this->debug_log( 'Product parent excluded from all-products rule', array( 'parent_id' => $parent_id ) ); return false; } $this->debug_log( 'Product matches (applies to all)' ); return true; } // Rule applies to specific products. if ( ! empty( $this->product_ids ) ) { // Check product ID. if ( in_array( $product_id, $this->product_ids, true ) ) { $this->debug_log( 'Product ID matches specific products list' ); return true; } // Check parent ID for variations. if ( $parent_id > 0 && in_array( $parent_id, $this->product_ids, true ) ) { $this->debug_log( 'Parent ID matches specific products list' ); return true; } } $this->debug_log( 'Product does not match rule criteria' ); return false; } /** * Calculate price based on rule. * THIS IS THE CRITICAL METHOD FOR QUANTITY-BASED PRICING. * * @param float $original_price Original price. * @param array $context Context data. * @return float Modified price. */ public function calculate_price( $original_price, $context ) { $quantity = isset( $context['quantity'] ) ? intval( $context['quantity'] ) : 1; $this->debug_log( '======= PRODUCT RULE CALCULATE START =======', array( 'rule_id' => $this->rule_id, 'rule_name' => $this->rule_name, 'product_id' => isset( $context['product_id'] ) ? $context['product_id'] : 'N/A', 'quantity' => $quantity, 'original_price' => $original_price, 'has_quantity_ranges' => ! empty( $this->quantity_ranges ), 'quantity_ranges_count' => count( $this->quantity_ranges ), ) ); // CRITICAL: Handle quantity-based pricing. if ( ! empty( $this->quantity_ranges ) ) { // We have quantity ranges - find matching range. $discount = $this->find_quantity_discount( $quantity ); if ( null === $discount ) { // No matching range found - return original price (NO DISCOUNT). $this->debug_log( '*** NO DISCOUNT APPLIED - Quantity outside all ranges ***', array( 'quantity' => $quantity, 'original_price' => $original_price, 'returning' => $original_price, ) ); return $original_price; } // Apply the discount. $final_price = $this->apply_discount( $original_price, $discount['type'], $discount['value'] ); // IMPORTANT: Ensure we're actually giving a discount. if ( $final_price >= $original_price ) { $this->debug_log( 'Calculated price not a discount, returning original', array( 'calculated' => $final_price, 'original' => $original_price, ) ); return $original_price; } $this->debug_log( 'Quantity discount applied successfully', array( 'quantity' => $quantity, 'discount_type' => $discount['type'], 'discount_value' => $discount['value'], 'original_price' => $original_price, 'final_price' => $final_price, 'savings' => $original_price - $final_price, ) ); return $final_price; } // No quantity ranges - use simple discount if available. if ( null !== $this->simple_discount ) { $final_price = $this->apply_discount( $original_price, $this->simple_discount['type'], $this->simple_discount['value'] ); return $final_price; } // No discount available. return $original_price; } /** * Find discount for given quantity. * CRITICAL: This must return null if quantity is outside ALL ranges. * * @param int $quantity Quantity to check. * @return array|null Discount array or null if no match. */ private function find_quantity_discount( $quantity ) { $this->debug_log( 'Finding discount for quantity', array( 'quantity' => $quantity, 'ranges_count' => count( $this->quantity_ranges ), 'ranges' => $this->quantity_ranges, ) ); // CRITICAL: First check if quantity exceeds ALL defined maximums. $highest_max = 0; $has_unlimited_range = false; foreach ( $this->quantity_ranges as $range ) { if ( null === $range['max_quantity'] ) { $has_unlimited_range = true; $this->debug_log( 'Found unlimited range' ); break; } else { $max = intval( $range['max_quantity'] ); if ( $max > $highest_max ) { $highest_max = $max; } } } $this->debug_log( 'Max quantity check', array( 'highest_max' => $highest_max, 'has_unlimited' => $has_unlimited_range, 'quantity' => $quantity, 'exceeds_max' => ( ! $has_unlimited_range && $highest_max > 0 && $quantity > $highest_max ), ) ); // If no unlimited range and quantity exceeds highest max, return null immediately. if ( ! $has_unlimited_range && $highest_max > 0 && $quantity > $highest_max ) { $this->debug_log( sprintf( 'QUANTITY %d EXCEEDS MAXIMUM LIMIT OF %d - NO DISCOUNT!', $quantity, $highest_max ) ); return null; } // Check each range. foreach ( $this->quantity_ranges as $index => $range ) { $min_qty = $range['min_quantity']; $max_qty = $range['max_quantity']; // Check if quantity is in this range. $in_range = false; if ( null === $max_qty ) { // No upper limit - check only minimum. $in_range = ( $quantity >= $min_qty ); } else { // Has upper limit - check both bounds. // STRICT CHECK: quantity must be <= max_qty. $in_range = ( $quantity >= $min_qty && $quantity <= $max_qty ); } $this->debug_log( "Checking range {$index}", array( 'min' => $min_qty, 'max' => null === $max_qty ? 'unlimited' : $max_qty, 'quantity' => $quantity, 'in_range' => $in_range, ) ); if ( $in_range ) { // Found matching range. $this->debug_log( 'Found matching range! Applying discount.' ); return array( 'type' => $range['discount_type'], 'value' => $range['discount_value'], ); } } // No matching range found. $this->debug_log( 'NO MATCHING RANGE for quantity ' . $quantity . ' - RETURNING NULL (no discount)' ); return null; } /** * Apply discount to price. * * @param float $price Original price. * @param string $discount_type Type of discount (percentage, fixed, fixed_price). * @param float $discount_value Discount value. * @return float Discounted price. */ private function apply_discount( $price, $discount_type, $discount_value ) { switch ( $discount_type ) { case 'percentage': // Calculate discount amount first, then subtract. $discount_amount = ( $price * $discount_value ) / 100; $discounted_price = $price - $discount_amount; // Round to 2 decimal places to avoid floating-point precision errors. return round( $discounted_price, 2 ); case 'fixed': case 'fixed_amount': return max( 0, round( $price - $discount_value, 2 ) ); case 'fixed_price': return round( $discount_value, 2 ); default: return round( $price, 2 ); } } /** * Get display price for frontend. * * @param WC_Product $product Product object. * @param string $user_role User role. * @return float|string */ public function get_display_price( $product, $user_role = null ) { if ( ! $product ) { return ''; } // Build context for single item. $context = array( 'product_id' => $product->get_id(), 'parent_id' => $product->get_parent_id(), 'quantity' => 1, 'user_role' => $user_role ? $user_role : 'guest', ); // Check if rule applies. if ( ! $this->is_applicable( $context ) ) { return $product->get_price(); } // Calculate and return price. return $this->calculate_price( $product->get_price(), $context ); } /** * Get bulk pricing table for product display. * * @param int $product_id Product ID. * @return array Bulk pricing table data. */ public function get_bulk_pricing_table( $product_id ) { if ( empty( $this->quantity_ranges ) ) { return array(); } $table = array(); foreach ( $this->quantity_ranges as $range ) { $table[] = array( 'min_quantity' => $range['min_quantity'], 'max_quantity' => $range['max_quantity'], 'discount_type' => $range['discount_type'], 'discount_value' => $range['discount_value'], ); } return $table; } /** * Load rule relationships from database. */ public function load_relationships() { if ( ! $this->rule_id ) { return; } // Check cache first. $cache_key = "dp_aicoso_rule_products_{$this->rule_id}"; $cached_relationships = wp_cache_get( $cache_key, 'dp_aicoso_rules' ); if ( false !== $cached_relationships ) { $relationships = $cached_relationships; } else { global $wpdb; // Get relationships for this rule with caching. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Product rule loading operation $relationships = $wpdb->get_results( $wpdb->prepare( 'SELECT object_id, object_type FROM `' . $wpdb->prefix . 'dp_aicoso_rule_relationships` WHERE rule_id = %d', intval( $this->rule_id ) ), ARRAY_A ); // Cache the result for 5 minutes. wp_cache_set( $cache_key, $relationships, 'dp_aicoso_rules', 300 ); } if ( empty( $relationships ) ) { // No specific products - apply to all. $this->apply_to_all = true; $this->debug_log( 'No relationships found - applying to all products' ); return; } // Process relationships. $product_ids = array(); foreach ( $relationships as $rel ) { if ( 'product' === $rel['object_type'] ) { $product_ids[] = intval( $rel['object_id'] ); } } if ( ! empty( $product_ids ) ) { $this->product_ids = $product_ids; $this->apply_to_all = false; $this->debug_log( 'Loaded product relationships', array( 'product_ids' => $this->product_ids, ) ); } else { $this->apply_to_all = true; $this->debug_log( 'No product relationships - applying to all' ); } } }