cache_manager = new DP_AICOSO_Cache_Manager(); } /** * Calculate product price with dynamic pricing rules. * * @param WC_Product $product Product object. * @param int $quantity Quantity. * @param WP_User $user User object (optional). * @param array $additional_context Additional context data (optional). * @return float Calculated price. */ public function calculate_product_price( $product, $quantity = 1, $user = null, $additional_context = array() ) { if ( ! $product ) { return 0; } // ALWAYS use regular price as the base to avoid compound discounts. $original_price = $product->get_regular_price( 'edit' ); // Check for WooCommerce sale price and use the lower of the two. $sale_price = $product->get_sale_price( 'edit' ); if ( ! empty( $sale_price ) && $sale_price > 0 ) { $original_price = min( floatval( $original_price ), floatval( $sale_price ) ); } if ( empty( $original_price ) || '' === $original_price ) { return 0; } // Build context and merge with additional context if provided. $context = $this->build_context( $product, $quantity, $user ); // Merge additional context (e.g., cart totals from WooCommerce integration). if ( ! empty( $additional_context ) ) { $context = array_merge( $context, $additional_context ); } // Debug logging for cart calculations. if ( doing_action( 'woocommerce_before_calculate_totals' ) && defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( 'DP_AICOSO Calculator - Cart Context:' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Product ID: ' . $product->get_id() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Product Type: ' . $product->get_type() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Parent ID: ' . $product->get_parent_id() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Quantity: ' . $quantity ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Original Price: ' . $original_price ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Context Product ID: ' . $context['product_id'] ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled error_log( ' Context Quantity: ' . $context['quantity'] ); } // Skip cache during cart calculations or when additional context is provided. $skip_cache = doing_action( 'woocommerce_before_calculate_totals' ) || is_cart() || is_checkout() || ! empty( $additional_context ); $cache_key = null; if ( ! $skip_cache ) { // Check cache first. $cache_key = $this->get_cache_key( $context ); $cached_price = $this->get_cached_price( $cache_key ); if ( false !== $cached_price ) { return $cached_price; } } else { $cache_key = $this->get_cache_key( $context ); } // Get applicable rules. $rules = $this->get_applicable_rules( $context ); if ( empty( $rules ) ) { // No rules apply, return original price. if ( ! $skip_cache ) { $this->cache_price( $cache_key, $original_price ); } return $original_price; } // Sort rules by priority. $rules = $this->sort_rules_by_priority( $rules ); // Apply rules. $final_price = $this->apply_rules( $original_price, $rules, $context ); // Validate final price. $final_price = $this->validate_price( $final_price, $original_price ); // Cache result only if not in cart context. if ( ! $skip_cache ) { $this->cache_price( $cache_key, $final_price ); } /** * Filter the calculated price before returning. * * @since 1.0.0 * * @param float $final_price Calculated price. * @param WC_Product $product Product object. * @param int $quantity Quantity. * @param WP_User $user User object. * @param array $context Context data. */ return apply_filters( 'dp_aicoso_calculated_price', $final_price, $product, $quantity, $user, $context ); } /** * Build context array for rule evaluation. * * @param WC_Product $product Product object. * @param int $quantity Quantity. * @param WP_User $user User object. * @return array Context data. */ private function build_context( $product, $quantity, $user ) { $context = array( 'product_id' => $product->get_id(), 'parent_id' => $product->get_parent_id(), 'product_type' => $product->get_type(), 'sku' => $product->get_sku(), 'quantity' => intval( $quantity ), 'timestamp' => current_time( 'timestamp' ), ); // Add category IDs. // For variations, categories are stored on the parent product, not the variation. $category_product_id = $product->get_id(); if ( $product->get_parent_id() > 0 ) { $category_product_id = $product->get_parent_id(); } $context['categories'] = wp_get_post_terms( $category_product_id, 'product_cat', array( 'fields' => 'ids' ) ); if ( is_wp_error( $context['categories'] ) ) { $context['categories'] = array(); } // Add tag IDs (also use parent for variations). $context['tags'] = wp_get_post_terms( $category_product_id, 'product_tag', array( 'fields' => 'ids' ) ); if ( is_wp_error( $context['tags'] ) ) { $context['tags'] = array(); } // Add user data. if ( ! $user ) { $user = wp_get_current_user(); } $context['user_id'] = $user->ID; $context['user_role'] = $user->ID > 0 ? ( ! empty( $user->roles ) ? $user->roles[0] : 'customer' ) : 'guest'; $context['user_roles'] = $user->ID > 0 ? $user->roles : array( 'guest' ); // Add cart data if available. if ( WC()->cart ) { // Calculate cart total using ORIGINAL prices to avoid circular dependency. $original_cart_total = 0; foreach ( WC()->cart->get_cart() as $cart_item ) { if ( isset( $cart_item['data'] ) && $cart_item['data'] ) { // Use stored base price if available, otherwise get regular price. if ( isset( $cart_item['dp_aicoso_base_price'] ) ) { $original_price = floatval( $cart_item['dp_aicoso_base_price'] ); } else { $original_price = floatval( $cart_item['data']->get_regular_price( 'edit' ) ); // Check for WooCommerce sale price. $sale_price = $cart_item['data']->get_sale_price( 'edit' ); if ( ! empty( $sale_price ) && $sale_price > 0 ) { $original_price = min( $original_price, floatval( $sale_price ) ); } } $original_cart_total += $original_price * $cart_item['quantity']; } } $context['cart_total'] = $original_cart_total; $context['cart_subtotal'] = $original_cart_total; // Add subtotal for clarity. $context['cart_quantity'] = WC()->cart->get_cart_contents_count(); } else { $context['cart_total'] = 0; $context['cart_subtotal'] = 0; $context['cart_quantity'] = 0; } // Add product metadata. $context['is_on_sale'] = $product->is_on_sale(); $context['stock_status'] = $product->get_stock_status(); $context['stock_quantity'] = $product->get_stock_quantity(); // Add coupon status to context. $context['has_coupons'] = WC()->cart && ! empty( WC()->cart->get_applied_coupons() ); /** * Filter the price context. * * @since 1.0.0 * * @param array $context Context data. * @param WC_Product $product Product object. * @param int $quantity Quantity. * @param WP_User $user User object. */ return apply_filters( 'dp_aicoso_price_context', $context, $product, $quantity, $user ); } /** * Get cache key for price calculation. * * @param array $context Context data. * @return string Cache key. */ private function get_cache_key( $context ) { $key_parts = array( 'price', $context['product_id'], $context['quantity'], $context['user_role'], $context['cart_total'], ); return 'dp_aicoso_' . md5( implode( '_', $key_parts ) ); } /** * Get cached price. * * @param string $cache_key Cache key. * @return float|false Cached price or false. */ private function get_cached_price( $cache_key ) { // Check memory cache first. if ( isset( $this->price_cache[ $cache_key ] ) ) { return $this->price_cache[ $cache_key ]; } // Check persistent cache. return $this->cache_manager->get( $cache_key ); } /** * Cache calculated price. * * @param string $cache_key Cache key. * @param float $price Price to cache. */ private function cache_price( $cache_key, $price ) { // Memory cache. $this->price_cache[ $cache_key ] = $price; // Persistent cache. $this->cache_manager->set( $cache_key, $price ); } /** * Get applicable rules for context. * * @param array $context Context data. * @return array Array of rule objects. */ private function get_applicable_rules( $context ) { $applicable_rules = array(); // Get all active rules. $all_rules = $this->get_all_active_rules(); foreach ( $all_rules as $rule ) { // Check if rule should be disabled based on user settings. if ( $rule->should_disable_rule( $context ) ) { continue; } if ( $rule->is_applicable( $context ) ) { $applicable_rules[] = $rule; } } /** * Filter the applicable rules. * * @since 1.0.0 * * @param array $applicable_rules The applicable rules. * @param array $context The calculation context. */ return apply_filters( 'dp_aicoso_applicable_rules', $applicable_rules, $context ); } /** * Get all active rules from database. * * @return array Array of rule objects. */ private function get_all_active_rules() { // Check in-memory cache first for this request. if ( ! empty( $this->rules_cache ) ) { return $this->rules_cache; } // Check transient cache for rule data (not objects). $cache_key = 'dp_aicoso_active_rules_data'; $cached_rule_data = get_transient( $cache_key ); if ( false !== $cached_rule_data && is_array( $cached_rule_data ) ) { // Create rule instances from cached data. $rules = array(); foreach ( $cached_rule_data as $rule_data ) { $rule = $this->create_rule_instance( $rule_data ); if ( $rule ) { $rules[] = $rule; } } // Store in memory cache for this request. $this->rules_cache = $rules; return $rules; } global $wpdb; $table_name = $wpdb->prefix . 'dp_aicoso_rules'; // Check if table exists before querying. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe table name, cached in parent method $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name; if ( ! $table_exists ) { // Return empty array if table doesn't exist. return array(); } // Query active rules with error suppression. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Results are cached with set_transient() for 5 minutes $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}dp_aicoso_rules WHERE rule_status = %s ORDER BY priority DESC, rule_id ASC", 'active' ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rules = array(); $rule_data_for_cache = array(); if ( $results && ! is_wp_error( $results ) ) { foreach ( $results as $rule_data ) { // Decode JSON fields before passing to rule instance. if ( isset( $rule_data['conditions'] ) ) { $rule_data['conditions'] = json_decode( $rule_data['conditions'], true ); if ( null === $rule_data['conditions'] ) { $rule_data['conditions'] = array(); } } if ( isset( $rule_data['adjustments'] ) ) { $rule_data['adjustments'] = json_decode( $rule_data['adjustments'], true ); if ( null === $rule_data['adjustments'] ) { $rule_data['adjustments'] = array(); } } // Store the decoded rule data for caching. $rule_data_for_cache[] = $rule_data; $rule = $this->create_rule_instance( $rule_data ); if ( $rule ) { $rules[] = $rule; } } } // Cache for 5 minutes - store the data arrays, not the objects. set_transient( $cache_key, $rule_data_for_cache, 300 ); // Also delete the old cache key if it exists. delete_transient( 'dp_aicoso_active_rules' ); // Store in memory cache for this request. $this->rules_cache = $rules; return $rules; } /** * Create rule instance from data. * * @param array $rule_data Rule data. * @return DP_AICOSO_Abstract_Pricing_Rule|null Rule instance or null. */ private function create_rule_instance( $rule_data ) { $rule_type = isset( $rule_data['rule_type'] ) ? $rule_data['rule_type'] : ''; switch ( $rule_type ) { case 'product': $rule = new DP_AICOSO_Product_Rule( $rule_data ); if ( method_exists( $rule, 'load_relationships' ) ) { $rule->load_relationships(); } return $rule; case 'category': $rule = new DP_AICOSO_Category_Rule( $rule_data ); if ( method_exists( $rule, 'load_relationships' ) ) { $rule->load_relationships(); } return $rule; case 'role': $rule = new DP_AICOSO_Role_Rule( $rule_data ); if ( method_exists( $rule, 'load_relationships' ) ) { $rule->load_relationships(); } return $rule; default: /** * Filter for creating custom rule instances. * * @since 1.0.0 * * @param null|DP_AICOSO_Abstract_Pricing_Rule $instance Rule instance. * @param array $rule_data Rule data. * @param string $rule_type Rule type. */ return apply_filters( 'dp_aicoso_create_rule_instance', null, $rule_data, $rule_type ); } } /** * Sort rules by priority. * * @param array $rules Array of rule objects. * @return array Sorted array of rules. */ private function sort_rules_by_priority( $rules ) { usort( $rules, function ( $a, $b ) { $priority_a = $a->get_priority(); $priority_b = $b->get_priority(); if ( $priority_a === $priority_b ) { // If same priority, sort by rule ID. return $a->get_id() - $b->get_id(); } // Higher priority first. return $priority_b - $priority_a; } ); return $rules; } /** * Apply rules to calculate final price. * * @param float $original_price Original price. * @param array $rules Array of rules. * @param array $context Context data. * @return float Final price. */ private function apply_rules( $original_price, $rules, $context ) { $price = $original_price; $applied_rules = array(); foreach ( $rules as $rule ) { // Calculate new price. $new_price = $rule->calculate_price( $price, $context ); // Check if price changed. if ( $new_price !== $price ) { $price = $new_price; $applied_rules[] = $rule->get_id(); // Check if should stop processing. if ( $rule->stop_processing() ) { break; } } } // Store applied rules in context for debugging. if ( ! empty( $applied_rules ) ) { $context['applied_rules'] = $applied_rules; /** * Action fired when pricing rules are applied. * * @since 1.0.0 * * @param array $applied_rules Array of applied rule IDs. * @param float $price Final price. * @param float $original_price Original price. * @param array $context Context data. */ do_action( 'dp_aicoso_rules_applied', $applied_rules, $price, $original_price, $context ); } return $price; } /** * Validate calculated price. * * @param float $price Calculated price. * @param float $original_price Original price. * @return float Validated price. */ private function validate_price( $price, $original_price ) { // Ensure price is not negative. $price = max( 0, $price ); // Check minimum price setting. $settings = get_option( 'dp_aicoso_settings', array() ); $min_price = isset( $settings['minimum_price'] ) ? floatval( $settings['minimum_price'] ) : 0; $price = max( $min_price, $price ); // Check maximum discount. $max_discount = isset( $settings['maximum_discount'] ) ? floatval( $settings['maximum_discount'] ) : 100; if ( $max_discount < 100 && $max_discount > 0 ) { $max_discounted_price = $original_price * ( 1 - $max_discount / 100 ); $price = max( $max_discounted_price, $price ); } /** * Filter the validated price. * * @since 1.0.0 * * @param float $price Validated price. * @param float $original_price Original price. */ return apply_filters( 'dp_aicoso_validate_price', $price, $original_price ); } /** * Clear all pricing caches. */ public function clear_cache() { // Clear memory cache. $this->price_cache = array(); $this->rules_cache = array(); // Clear transients (both old and new cache keys). delete_transient( 'dp_aicoso_active_rules' ); delete_transient( 'dp_aicoso_active_rules_data' ); // Clear cache manager cache - this handles all cache types safely. if ( $this->cache_manager ) { $this->cache_manager->flush(); } else { // Fallback when no cache manager: clear our main transients and object cache. delete_transient( 'dp_aicoso_active_rules' ); wp_cache_flush(); } /** * Action fired when cache is cleared. * * @since 1.0.0 */ do_action( 'dp_aicoso_cache_cleared' ); } /** * Get bulk pricing table for a product. * * @param int $product_id Product ID. * @return array Bulk pricing table data. */ public function get_bulk_pricing_table( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return array(); } // Get applicable rules. $context = $this->build_context( $product, 1, wp_get_current_user() ); $rules = $this->get_applicable_rules( $context ); $table_data = array(); foreach ( $rules as $rule ) { if ( $rule instanceof DP_AICOSO_Product_Rule ) { $rule_table = $rule->get_bulk_pricing_table( $product_id ); if ( ! empty( $rule_table ) ) { $table_data = array_merge( $table_data, $rule_table ); } } } // Remove duplicates and sort. $table_data = $this->consolidate_pricing_table( $table_data ); /** * Filter the bulk pricing table data. * * @since 1.0.0 * * @param array $table_data Array of bulk pricing data. * @param int $product_id Product ID. */ return apply_filters( 'dp_aicoso_bulk_pricing_table', $table_data, $product_id ); } /** * Consolidate pricing table data. * * @param array $table_data Raw table data. * @return array Consolidated table data. */ private function consolidate_pricing_table( $table_data ) { if ( empty( $table_data ) ) { return array(); } // Sort by minimum quantity. usort( $table_data, function ( $a, $b ) { return $a['min_quantity'] - $b['min_quantity']; } ); // Remove overlapping ranges. $consolidated = array(); $last_max = 0; foreach ( $table_data as $row ) { if ( $row['min_quantity'] > $last_max ) { $consolidated[] = $row; $last_max = isset( $row['max_quantity'] ) ? $row['max_quantity'] : PHP_INT_MAX; } } return $consolidated; } }