JezK
Edit File: class-cartflows-pro-ability-runtime.php
<?php /** * CartFlows Pro — Ability Runtime * * Base class with shared helpers (input parsing, permissions, error formatting) * and execute callbacks for all registered CartFlows Pro abilities. * * @package cartflows-pro * @since 2.2.2 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Class Cartflows_Pro_Ability_Runtime * * Provides: * - register_categories() / register() — WordPress Abilities API wiring * - permission_callback() — Capability check helper * - input_parse() / input_get() — Input validation against schema * - error() — Structured error response (WP_DEBUG-aware) * - One public method per registered ability * * @since 2.2.2 */ class Cartflows_Pro_Ability_Runtime { /** * Parsed input values for the current execution. * * Protected to prevent external manipulation between calls. * * @var array|false */ protected $input = false; // ============================================================ // Registration // ============================================================ /** * Register the cartflows-pro ability category. * * Hooked on wp_abilities_api_categories_init. * * @since 2.2.2 * @return void */ public function register_categories() { wp_register_ability_category( 'cartflows-pro', array( 'label' => __( 'CartFlows Pro', 'cartflows-pro' ), 'description' => __( 'Abilities for CartFlows Pro — analytics, funnel management, and offer configuration.', 'cartflows-pro' ), ) ); } /** * Register all CartFlows Pro abilities. * * Hooked on wp_abilities_api_init. * * @since 2.2.2 * @return void */ public function register() { $abilities = Cartflows_Pro_Ability_Config::get_abilities(); foreach ( $abilities as $ability_name => $ability ) { wp_register_ability( $ability_name, array( 'label' => $ability['label'], 'description' => $ability['description'], 'category' => $ability['category'], 'input_schema' => $ability['input_schema'], 'output_schema' => $ability['output_schema'], 'execute_callback' => $ability['execute_callback'], 'permission_callback' => $ability['permission_callback'], 'meta' => $ability['meta'], ) ); } } // ============================================================ // Execute Callbacks — Offer / Upsell / Downsell // ============================================================ /** * Execute: cartflows-pro/list-offer-steps * * Returns a paginated list of upsell and downsell steps, optionally filtered * by funnel (flow_id) and/or step type (upsell/downsell/all). * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Steps list or error. * @throws Exception On Failure. */ public function list_offer_steps( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-offer-steps' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $flow_id = $this->input_get( 'flow_id' ); $step_type = $this->input_get( 'step_type' ); $page = max( 1, $this->input_get( 'page' ) ); $per_page = min( 100, max( 1, $this->input_get( 'per_page' ) ) ); // Build query args for wcf-step posts with type upsell/downsell. $meta_query = array( 'relation' => 'AND', array( 'key' => 'wcf-step-type', 'value' => array( 'upsell', 'downsell' ), 'compare' => 'IN', ), ); if ( 'all' !== $step_type ) { $meta_query[] = array( 'key' => 'wcf-step-type', 'value' => $step_type, ); } if ( $flow_id > 0 ) { $meta_query[] = array( 'key' => 'wcf-flow-id', 'value' => $flow_id, ); } $query = new WP_Query( array( 'post_type' => CARTFLOWS_STEP_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => $per_page, 'paged' => $page, 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'no_found_rows' => false, 'fields' => 'all', ) ); $steps = array(); $total = (int) $query->found_posts; $total_pages = (int) $query->max_num_pages; foreach ( $query->posts as $post ) { $step_id = (int) $post->ID; $step_flow_id = (int) get_post_meta( $step_id, 'wcf-flow-id', true ); $type = get_post_meta( $step_id, 'wcf-step-type', true ); $product_raw = get_post_meta( $step_id, 'wcf-offer-product', true ); $product_id = ( is_array( $product_raw ) && ! empty( $product_raw[0] ) ) ? (int) $product_raw[0] : 0; $product_name = ''; if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); $product_name = $product_obj ? $product_obj->get_name() : ''; } $discount_type = (string) get_post_meta( $step_id, 'wcf-offer-discount', true ); $discount_value = (float) get_post_meta( $step_id, 'wcf-offer-discount-value', true ); $is_disabled = 'yes' === get_post_meta( $step_id, 'wcf-disable-step', true ); $skip_if_owned = 'yes' === get_post_meta( $step_id, 'wcf-skip-offer', true ); $steps[] = array( 'step_id' => $step_id, 'flow_id' => $step_flow_id, 'title' => get_the_title( $step_id ), 'step_type' => esc_html( (string) $type ), 'product_id' => $product_id, 'product_name' => esc_html( $product_name ), 'discount_type' => esc_html( $discount_type ), 'discount_value' => $discount_value, 'is_disabled' => $is_disabled, 'skip_if_owned' => $skip_if_owned, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } return array( 'steps' => $steps, 'total' => $total, 'total_pages' => $total_pages, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-offer-step * * Returns full configuration of a single upsell or downsell step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Step config or error. * @throws Exception On Failure. */ public function get_offer_step( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-offer-step' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = get_post_meta( $step_id, 'wcf-step-type', true ); if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { throw new Exception( esc_html__( 'Step is not an offer step (upsell or downsell).', 'cartflows-pro' ) ); } $flow_id = (int) get_post_meta( $step_id, 'wcf-flow-id', true ); $product_raw = get_post_meta( $step_id, 'wcf-offer-product', true ); $product_id = ( is_array( $product_raw ) && ! empty( $product_raw[0] ) ) ? (int) $product_raw[0] : 0; $product_name = ''; $product_price = 0.0; if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); $product_name = $product_obj ? $product_obj->get_name() : ''; $product_price = $product_obj ? (float) $product_obj->get_regular_price() : 0.0; } $discount_type = (string) get_post_meta( $step_id, 'wcf-offer-discount', true ); $discount_value = (float) get_post_meta( $step_id, 'wcf-offer-discount-value', true ); $yes_next_step_id = (int) get_post_meta( $step_id, 'wcf-yes-next-step', true ); $no_next_step_id = (int) get_post_meta( $step_id, 'wcf-no-next-step', true ); $is_disabled = 'yes' === get_post_meta( $step_id, 'wcf-disable-step', true ); $skip_if_owned = 'yes' === get_post_meta( $step_id, 'wcf-skip-offer', true ); $replace_main_order = 'yes' === get_post_meta( $step_id, 'wcf-replace-main-order', true ); return array( 'step_id' => $step_id, 'flow_id' => $flow_id, 'title' => get_the_title( $step_id ), 'step_type' => esc_html( $step_type ), 'product_id' => $product_id, 'product_name' => esc_html( $product_name ), 'product_price' => $product_price, 'discount_type' => esc_html( $discount_type ), 'discount_value' => $discount_value, 'yes_next_step_id' => $yes_next_step_id, 'no_next_step_id' => $no_next_step_id, 'is_disabled' => $is_disabled, 'skip_if_owned' => $skip_if_owned, 'replace_main_order' => $replace_main_order, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), 'url_preview' => esc_url_raw( get_permalink( $step_id ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/update-offer-step-product * * Sets or changes the product assigned to an offer step, including discount * type and discount value. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Updated step data or error. * @throws Exception On Failure. */ public function update_offer_step_product( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'update-offer-step-product' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $product_id = $this->input_get( 'product_id' ); $discount_type = $this->input_get( 'discount_type' ); $discount_value = $this->input_get( 'discount_value' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } if ( $product_id <= 0 ) { throw new Exception( esc_html__( 'Invalid product ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = get_post_meta( $step_id, 'wcf-step-type', true ); if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { throw new Exception( esc_html__( 'Step is not an offer step (upsell or downsell).', 'cartflows-pro' ) ); } $product_obj = wc_get_product( $product_id ); if ( ! $product_obj ) { throw new Exception( esc_html__( 'Product not found.', 'cartflows-pro' ) ); } // Update post meta — product stored as array (index 0 = product ID). update_post_meta( $step_id, 'wcf-offer-product', array( $product_id ) ); update_post_meta( $step_id, 'wcf-offer-discount', $discount_type ); update_post_meta( $step_id, 'wcf-offer-discount-value', $discount_value ); return array( 'step_id' => $step_id, 'product_id' => $product_id, 'product_name' => esc_html( $product_obj->get_name() ), 'discount_type' => esc_html( $discount_type ), 'discount_value' => $discount_value, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/toggle-offer-step * * Enables or disables a upsell or downsell step by setting wcf-disable-step meta. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Toggle result or error. * @throws Exception On Failure. */ public function toggle_offer_step( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'toggle-offer-step' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $disabled = $this->input_get( 'disabled' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = get_post_meta( $step_id, 'wcf-step-type', true ); if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { throw new Exception( esc_html__( 'Step is not an offer step (upsell or downsell).', 'cartflows-pro' ) ); } $meta_value = $disabled ? 'yes' : 'no'; update_post_meta( $step_id, 'wcf-disable-step', $meta_value ); if ( $disabled ) { $message = __( 'Offer step disabled.', 'cartflows-pro' ); } else { $message = __( 'Offer step enabled.', 'cartflows-pro' ); } return array( 'step_id' => $step_id, 'is_disabled' => $disabled, 'message' => $message, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/update-offer-step-routing * * Sets the yes-next-step and no-next-step routing meta for an offer step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Routing result or error. * @throws Exception On Failure. */ public function update_offer_step_routing( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'update-offer-step-routing' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $yes_next_step_id = $this->input_get( 'yes_next_step_id' ); $no_next_step_id = $this->input_get( 'no_next_step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = get_post_meta( $step_id, 'wcf-step-type', true ); if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { throw new Exception( esc_html__( 'Step is not an offer step (upsell or downsell).', 'cartflows-pro' ) ); } // Store as empty string when 0 (matching the default in the meta schema). $yes_meta = $yes_next_step_id > 0 ? (string) $yes_next_step_id : ''; $no_meta = $no_next_step_id > 0 ? (string) $no_next_step_id : ''; update_post_meta( $step_id, 'wcf-yes-next-step', $yes_meta ); update_post_meta( $step_id, 'wcf-no-next-step', $no_meta ); return array( 'step_id' => $step_id, 'yes_next_step_id' => $yes_next_step_id, 'no_next_step_id' => $no_next_step_id, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-offer-order-details * * Returns CartFlows offer metadata for a WooCommerce order. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Offer order details or error. * @throws Exception On Failure. */ public function get_offer_order_details( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-offer-order-details' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $order_id = $this->input_get( 'order_id' ); if ( $order_id <= 0 ) { throw new Exception( esc_html__( 'Invalid order ID.', 'cartflows-pro' ) ); } $order = wc_get_order( $order_id ); if ( ! $order ) { throw new Exception( esc_html__( 'Order not found.', 'cartflows-pro' ) ); } $is_offer = 'yes' === $order->get_meta( '_cartflows_offer' ); $offer_type = (string) $order->get_meta( '_cartflows_offer_type' ); $parent_id_raw = $order->get_meta( '_cartflows_offer_parent_id' ); $parent_id = ! empty( $parent_id_raw ) ? (int) $parent_id_raw : 0; $amount_diff = (float) $order->get_meta( '_cartflows_offer_amount_diff' ); // Build child orders list (only present on parent/main orders). $child_orders = array(); $child_orders_raw = $order->get_meta( '_cartflows_offer_child_orders' ); if ( is_array( $child_orders_raw ) ) { foreach ( $child_orders_raw as $child_id => $data ) { $child_order = wc_get_order( (int) $child_id ); if ( $child_order ) { $child_type = (string) $child_order->get_meta( '_cartflows_offer_type' ); $child_orders[] = array( 'order_id' => (int) $child_id, 'offer_type' => esc_html( $child_type ), ); } } } return array( 'order_id' => $order_id, 'is_offer_order' => $is_offer, 'offer_type' => esc_html( $offer_type ), 'parent_order_id' => $parent_id, 'amount_charged' => $amount_diff, 'child_offer_orders' => $child_orders, 'url_edit' => esc_url_raw( $order->get_edit_order_url() ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/list-offer-orders * * Returns a paginated list of WooCommerce orders that are CartFlows offer * orders (upsell or downsell). * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Offer orders list or error. * @throws Exception On Failure. */ public function list_offer_orders( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-offer-orders' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $offer_type = $this->input_get( 'offer_type' ); $flow_id = $this->input_get( 'flow_id' ); $page = max( 1, $this->input_get( 'page' ) ); $per_page = min( 100, max( 1, $this->input_get( 'per_page' ) ) ); // Build meta_query to identify CartFlows offer orders. $meta_query = array( 'relation' => 'AND', array( 'key' => '_cartflows_offer', 'value' => 'yes', ), ); if ( 'all' !== $offer_type ) { $meta_query[] = array( 'key' => '_cartflows_offer_type', 'value' => $offer_type, ); } // flow_id filter is not stored directly on offer orders; skip if requested, // as there is no reliable direct order-level meta for flow_id on offer orders. $offset = ( $page - 1 ) * $per_page; $wc_orders = wc_get_orders( array( 'limit' => $per_page, 'offset' => $offset, 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'return' => 'objects', 'paginate' => true, ) ); $orders_data = array(); foreach ( $wc_orders->orders as $order ) { $o_id = (int) $order->get_id(); $o_parent_id_raw = $order->get_meta( '_cartflows_offer_parent_id' ); $o_parent_id = ! empty( $o_parent_id_raw ) ? (int) $o_parent_id_raw : 0; $o_offer_type = (string) $order->get_meta( '_cartflows_offer_type' ); $o_date = $order->get_date_created(); $orders_data[] = array( 'order_id' => $o_id, 'parent_order_id' => $o_parent_id, 'offer_type' => esc_html( $o_offer_type ), 'total' => (float) $order->get_total(), 'currency' => esc_html( $order->get_currency() ), 'status' => esc_html( $order->get_status() ), 'date_created' => $o_date ? $o_date->date( 'c' ) : '', 'url_edit' => esc_url_raw( $order->get_edit_order_url() ), ); } $total = (int) $wc_orders->total; $total_pages = (int) $wc_orders->max_num_pages; return array( 'orders' => $orders_data, 'total' => $total, 'total_pages' => $total_pages, ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Execute Callbacks — Analytics / Tracking // ============================================================ /** * Execute: cartflows-pro/get-funnel-analytics * * Returns funnel-level revenue and visit analytics for a date range. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Analytics data or error. * @throws Exception On Failure. */ public function get_funnel_analytics( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-funnel-analytics' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $start_date = $this->input_get( 'start_date' ); $end_date = $this->input_get( 'end_date' ); $flow_id = $this->input_get( 'flow_id' ); $comparison_range_type = $this->input_get( 'comparison_range_type' ); // Pass empty string for all-flows; positive int for specific flow. $dashboard_flow_id = $flow_id > 0 ? (string) $flow_id : ''; $analytics_data = wcf_pro_analytics()->funnels->get_analytics_data( array(), $start_date, $end_date, $dashboard_flow_id, $comparison_range_type ); return $analytics_data; } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-conversion-analytics * * Returns conversion-focused analytics for a date range. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Analytics data or error. * @throws Exception On Failure. */ public function get_conversion_analytics( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-conversion-analytics' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $start_date = $this->input_get( 'start_date' ); $end_date = $this->input_get( 'end_date' ); $flow_id = $this->input_get( 'flow_id' ); $comparison_range_type = $this->input_get( 'comparison_range_type' ); $dashboard_flow_id = $flow_id > 0 ? (string) $flow_id : ''; $analytics_data = wcf_pro_analytics()->conversions->get_analytics_data( array(), $start_date, $end_date, $dashboard_flow_id, $comparison_range_type ); return $analytics_data; } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-flow-earnings * * Returns detailed earnings breakdown for a specific funnel. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Earnings data or error. * @throws Exception On Failure. */ public function get_flow_earnings( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-flow-earnings' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $flow_id = $this->input_get( 'flow_id' ); $start_date = $this->input_get( 'start_date' ); $end_date = $this->input_get( 'end_date' ); if ( $flow_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow ID.', 'cartflows-pro' ) ); } // Verify the flow post exists and is a CartFlows flow. $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } $earnings = wcf_pro_analytics()->database->get_earnings( $flow_id, $start_date, $end_date ); return $earnings; } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-gross-sale-by-flow * * Returns the all-time gross sale total for a specific funnel. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Gross sale data or error. * @throws Exception On Failure. */ public function get_gross_sale_by_flow( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-gross-sale-by-flow' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $flow_id = $this->input_get( 'flow_id' ); if ( $flow_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow ID.', 'cartflows-pro' ) ); } $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } $reports = Cartflows_Pro_Analytics_Reports::get_instance(); $raw_value = $reports->get_gross_sale_by_flow( $flow_id ); return array( 'flow_id' => $flow_id, 'gross_sale' => (float) $raw_value, 'gross_sale_formatted' => wp_strip_all_tags( wcf_pro_analytics()->format_price( $raw_value ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-optin-analytics * * Returns optin-specific analytics for a date range. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Analytics data or error. * @throws Exception On Failure. */ public function get_optin_analytics( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-optin-analytics' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $start_date = $this->input_get( 'start_date' ); $end_date = $this->input_get( 'end_date' ); $flow_id = $this->input_get( 'flow_id' ); $comparison_range_type = $this->input_get( 'comparison_range_type' ); $dashboard_flow_id = $flow_id > 0 ? (string) $flow_id : ''; $analytics_data = wcf_pro_analytics()->optin->get_analytics_data( array(), $start_date, $end_date, $dashboard_flow_id, $comparison_range_type ); return $analytics_data; } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/reset-flow-analytics * * Resets analytics for a funnel by setting the analytics reset date to today. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function reset_flow_analytics( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'reset-flow-analytics' ); $flow_id = $this->input_get( 'flow_id' ); if ( $flow_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow ID.', 'cartflows-pro' ) ); } $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } $reset_date = gmdate( 'Y-m-d H:i:s' ); $updated = update_post_meta( $flow_id, 'wcf-analytics-reset-date', $reset_date ); return array( 'success' => (bool) $updated, 'flow_id' => $flow_id, 'reset_date' => gmdate( 'Y-m-d', strtotime( $reset_date ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-step-analytics * * Returns analytics for a single step or all steps in a funnel. * step_id takes precedence over flow_id if both are provided. * At least one of step_id or flow_id must be a positive integer. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Analytics data or error. * @throws Exception On Failure. */ public function get_step_analytics( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-step-analytics' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $flow_id = $this->input_get( 'flow_id' ); $start_date = $this->input_get( 'start_date' ); $end_date = $this->input_get( 'end_date' ); // At least one of step_id or flow_id must be provided. if ( $step_id <= 0 && $flow_id <= 0 ) { throw new Exception( esc_html__( 'Either step_id or flow_id must be provided.', 'cartflows-pro' ) ); } // Apply date defaults: last 30 days. if ( empty( $start_date ) ) { $start_date = gmdate( 'Y-m-d', strtotime( '-30 days' ) ); } if ( empty( $end_date ) ) { $end_date = gmdate( 'Y-m-d' ); } // step_id takes precedence. if ( $step_id > 0 ) { return $this->get_single_step_analytics( $step_id, $start_date, $end_date ); } return $this->get_all_flow_steps_analytics( $flow_id, $start_date, $end_date ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Private Helpers — Step Analytics // ============================================================ /** * Get analytics for a single step. * * @since 2.2.2 * @param int $step_id Step post ID. * @param string $start_date Start date (Y-m-d). * @param string $end_date End date (Y-m-d). * @return array * @throws Exception When step is not found. */ private function get_single_step_analytics( $step_id, $start_date, $end_date ) { global $wpdb; $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = function_exists( 'wcf' ) ? wcf()->utils->get_step_type( $step_id ) : ''; // Fetch visit data for this single step. $visit_db = $wpdb->prefix . CARTFLOWS_PRO_VISITS_TABLE; $visit_meta_db = $wpdb->prefix . CARTFLOWS_PRO_VISITS_META_TABLE; $start_datetime = wcf_pro_analytics()->format_date_time( $start_date, false ); $end_datetime = wcf_pro_analytics()->format_date_time( $end_date, true ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $query = $wpdb->prepare( "SELECT COUNT( DISTINCT( {$visit_db}.id ) ) AS total_visits, COUNT( DISTINCT( CASE WHEN {$visit_db}.visit_type = 'new' THEN {$visit_db}.id ELSE NULL END ) ) AS unique_visits, COUNT( CASE WHEN {$visit_meta_db}.meta_key = 'conversion' AND {$visit_meta_db}.meta_value = 'yes' THEN {$visit_db}.id ELSE NULL END ) AS conversions FROM {$visit_db} INNER JOIN {$visit_meta_db} ON {$visit_db}.id = {$visit_meta_db}.visit_id WHERE {$visit_db}.step_id = %d AND ( {$visit_db}.date_visited BETWEEN %s AND %s )", $step_id, $start_datetime, $end_datetime ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $total_visits = $row ? intval( $row->total_visits ) : 0; $unique_visits = $row ? intval( $row->unique_visits ) : 0; $conversions = $row ? intval( $row->conversions ) : 0; $conversion_rate = '0.00'; if ( $total_visits > 0 ) { $conversion_rate = number_format( ( $conversions / $total_visits ) * 100, 2, '.', '' ); } // Fetch revenue for this step. $revenue = $this->get_step_revenue( $step_id, $step_type, $start_date, $end_date ); return array( 'mode' => 'single', 'step' => array( 'step_id' => $step_id, 'step_title' => get_the_title( $step_id ), 'step_type' => $step_type, 'total_visits' => $total_visits, 'unique_visits' => $unique_visits, 'conversions' => $conversions, 'conversion_rate' => $conversion_rate, 'revenue' => $revenue, ), ); } /** * Get analytics for all steps in a flow. * * Re-uses existing database and calculations infrastructure. * * @since 2.2.2 * @param int $flow_id Flow post ID. * @param string $start_date Start date (Y-m-d). * @param string $end_date End date (Y-m-d). * @return array * @throws Exception When flow is not found or has no steps. */ private function get_all_flow_steps_analytics( $flow_id, $start_date, $end_date ) { $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } // Fetch per-step visit data using the existing database helper. $visits = wcf_pro_analytics()->database->fetch_visits( $flow_id, $start_date, $end_date ); $earning = wcf_pro_analytics()->database->get_earnings( $flow_id, $start_date, $end_date ); // Build the steps-with-analytics structure using calculations helper. $steps_with_analytics = wcf_pro_analytics()->calculations->build_steps_with_analytics( $flow_id, $visits, $earning ); // Flatten to our output schema shape. $steps = array(); foreach ( $steps_with_analytics as $step_data ) { $step_id = isset( $step_data['id'] ) ? intval( $step_data['id'] ) : 0; $step_type = function_exists( 'wcf' ) ? wcf()->utils->get_step_type( $step_id ) : ''; $visit_info = isset( $step_data['visits'] ) ? $step_data['visits'] : array(); $total_visits = isset( $visit_info['total_visits'] ) ? intval( $visit_info['total_visits'] ) : 0; $unique_visits = isset( $visit_info['unique_visits'] ) ? intval( $visit_info['unique_visits'] ) : 0; $conversions = isset( $visit_info['conversions'] ) ? intval( $visit_info['conversions'] ) : 0; $conversion_rate = isset( $visit_info['conversion_rate'] ) ? (string) $visit_info['conversion_rate'] : '0.00'; $revenue = isset( $visit_info['revenue'] ) ? (string) $visit_info['revenue'] : '0.00'; $steps[] = array( 'step_id' => $step_id, 'step_title' => isset( $visit_info['title'] ) ? esc_html( $visit_info['title'] ) : esc_html( get_the_title( $step_id ) ), 'step_type' => esc_html( $step_type ), 'total_visits' => $total_visits, 'unique_visits' => $unique_visits, 'conversions' => $conversions, 'conversion_rate' => $conversion_rate, 'revenue' => $revenue, ); } return array( 'mode' => 'flow', 'steps' => $steps, ); } /** * Get revenue attributed to a specific step. * * For checkout steps: sums checkout sale. For upsell/downsell steps: sums offer earnings. * For other step types: returns '0.00'. * * @since 2.2.2 * @param int $step_id Step ID. * @param string $step_type Step type (checkout, upsell, downsell, etc.). * @param string $start_date Start date (Y-m-d). * @param string $end_date End date (Y-m-d). * @return string Revenue formatted as number string (e.g. "125.50"). */ private function get_step_revenue( $step_id, $step_type, $start_date, $end_date ) { if ( ! in_array( $step_type, array( 'checkout', 'upsell', 'downsell' ), true ) ) { return '0.00'; } // Find the parent flow for this step. $flow_id = get_post_meta( $step_id, '_cartflows_flow', true ); if ( empty( $flow_id ) ) { // Fallback: search for flow via step relationships. $flow_id = wcf()->utils->get_flow_id_from_step_id( $step_id ); } if ( empty( $flow_id ) ) { return '0.00'; } $earning = wcf_pro_analytics()->database->get_earnings( (int) $flow_id, $start_date, $end_date ); if ( 'checkout' === $step_type && isset( $earning['checkout'][ $step_id ] ) ) { return number_format( (float) $earning['checkout'][ $step_id ], 2, '.', '' ); } if ( in_array( $step_type, array( 'upsell', 'downsell' ), true ) && isset( $earning['offer'][ $step_id ] ) ) { return number_format( (float) $earning['offer'][ $step_id ], 2, '.', '' ); } return '0.00'; } // ============================================================ // Execute Callbacks — Checkout Module // ============================================================ /** * Execute: cartflows-pro/list-order-bumps * * Returns all order bumps configured on a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Order bumps list or error. * @throws Exception On Failure. */ public function list_order_bumps( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-order-bumps' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $order_bumps = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-bumps' ); $result = array(); if ( is_array( $order_bumps ) && ! empty( $order_bumps ) ) { foreach ( $order_bumps as $ob ) { $product_id = ! empty( $ob['product'] ) ? intval( $ob['product'] ) : 0; $product_name = ''; if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); $product_name = $product_obj ? $product_obj->get_name() : ''; } $result[] = array( 'ob_id' => isset( $ob['id'] ) ? (string) $ob['id'] : '', 'title' => isset( $ob['title'] ) ? esc_html( (string) $ob['title'] ) : '', 'status' => isset( $ob['status'] ) ? (bool) $ob['status'] : false, 'product_id' => $product_id, 'product_name' => esc_html( $product_name ), 'quantity' => isset( $ob['quantity'] ) ? intval( $ob['quantity'] ) : 1, 'discount_type' => isset( $ob['discount_type'] ) ? esc_html( (string) $ob['discount_type'] ) : '', 'discount_value' => isset( $ob['discount_value'] ) ? floatval( $ob['discount_value'] ) : 0, 'position' => isset( $ob['position'] ) ? esc_html( (string) $ob['position'] ) : 'after-order', 'style' => isset( $ob['style'] ) ? esc_html( (string) $ob['style'] ) : 'style-1', 'default_state' => isset( $ob['default_state'] ) ? esc_html( (string) $ob['default_state'] ) : 'no', 'action_element' => isset( $ob['action_element'] ) ? esc_html( (string) $ob['action_element'] ) : 'checkbox', 'display_quantity_field' => isset( $ob['display_quantity_field'] ) ? esc_html( (string) $ob['display_quantity_field'] ) : 'no', 'has_rules' => isset( $ob['is_rule'] ) && 'yes' === $ob['is_rule'], 'replace_product' => isset( $ob['replace_product'] ) ? esc_html( (string) $ob['replace_product'] ) : 'no', ); } } return array( 'step_id' => $step_id, 'order_bumps' => $result, 'total' => count( $result ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-order-bump * * Returns the full configuration of a single order bump. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Order bump config or error. * @throws Exception On Failure. */ public function get_order_bump( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-order-bump' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $ob_id = $this->input_get( 'ob_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $order_bumps = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-bumps' ); $found = null; if ( is_array( $order_bumps ) ) { foreach ( $order_bumps as $ob ) { if ( isset( $ob['id'] ) && (string) $ob['id'] === $ob_id ) { $found = $ob; break; } } } if ( null === $found ) { throw new Exception( esc_html__( 'Order bump not found.', 'cartflows-pro' ) ); } $product_id = ! empty( $found['product'] ) ? intval( $found['product'] ) : 0; $product_name = ''; if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); $product_name = $product_obj ? $product_obj->get_name() : ''; } return array( 'step_id' => $step_id, 'ob_id' => (string) $found['id'], 'title' => isset( $found['title'] ) ? esc_html( (string) $found['title'] ) : '', 'status' => isset( $found['status'] ) ? (bool) $found['status'] : false, 'product_id' => $product_id, 'product_name' => esc_html( $product_name ), 'quantity' => isset( $found['quantity'] ) ? intval( $found['quantity'] ) : 1, 'discount_type' => isset( $found['discount_type'] ) ? esc_html( (string) $found['discount_type'] ) : '', 'discount_value' => isset( $found['discount_value'] ) ? floatval( $found['discount_value'] ) : 0, 'position' => isset( $found['position'] ) ? esc_html( (string) $found['position'] ) : 'after-order', 'style' => isset( $found['style'] ) ? esc_html( (string) $found['style'] ) : 'style-1', 'width' => isset( $found['width'] ) ? esc_html( (string) $found['width'] ) : '100', 'default_state' => isset( $found['default_state'] ) ? esc_html( (string) $found['default_state'] ) : 'no', 'action_element' => isset( $found['action_element'] ) ? esc_html( (string) $found['action_element'] ) : 'checkbox', 'display_quantity_field' => isset( $found['display_quantity_field'] ) ? esc_html( (string) $found['display_quantity_field'] ) : 'no', 'checkbox_label' => isset( $found['checkbox_label'] ) ? esc_html( (string) $found['checkbox_label'] ) : '', 'title_text' => isset( $found['title_text'] ) ? esc_html( (string) $found['title_text'] ) : '', 'hl_text' => isset( $found['hl_text'] ) ? esc_html( (string) $found['hl_text'] ) : '', 'desc_text' => isset( $found['desc_text'] ) ? wp_kses_post( (string) $found['desc_text'] ) : '', 'replace_product' => isset( $found['replace_product'] ) ? esc_html( (string) $found['replace_product'] ) : 'no', 'next_step' => isset( $found['next_step'] ) ? esc_html( (string) $found['next_step'] ) : '', 'enable_show_image' => isset( $found['enable_show_image'] ) ? esc_html( (string) $found['enable_show_image'] ) : 'no', 'ob_image_position' => isset( $found['ob_image_position'] ) ? esc_html( (string) $found['ob_image_position'] ) : 'left', 'has_rules' => isset( $found['is_rule'] ) && 'yes' === $found['is_rule'], ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/update-order-bump * * Updates the configuration of an existing order bump. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Update result or error. * @throws Exception On Failure. */ public function update_order_bump( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'update-order-bump' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $ob_id = $this->input_get( 'ob_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $order_bumps = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-bumps' ); if ( ! is_array( $order_bumps ) ) { throw new Exception( esc_html__( 'No order bumps found on this step.', 'cartflows-pro' ) ); } $found_index = null; foreach ( $order_bumps as $index => $ob ) { if ( isset( $ob['id'] ) && (string) $ob['id'] === $ob_id ) { $found_index = $index; break; } } if ( null === $found_index ) { throw new Exception( esc_html__( 'Order bump not found.', 'cartflows-pro' ) ); } // Map of input field => order bump array key. $field_map = array( 'product' => 'product', 'discount_type' => 'discount_type', 'discount_value' => 'discount_value', 'position' => 'position', 'style' => 'style', 'status' => 'status', 'default_state' => 'default_state', 'checkbox_label' => 'checkbox_label', 'title_text' => 'title_text', 'hl_text' => 'hl_text', 'desc_text' => 'desc_text', 'action_element' => 'action_element', 'display_quantity_field' => 'display_quantity_field', 'quantity' => 'quantity', ); $fields_set = array(); foreach ( $field_map as $input_key => $ob_key ) { $value = $this->input_get( $input_key ); // Skip sentinel/unchanged values. if ( '__unchanged__' === $value ) { continue; } // For product and quantity, 0 means "leave unchanged". if ( in_array( $input_key, array( 'product', 'quantity' ), true ) && 0 === $value ) { continue; } // For discount_value, -1 means "leave unchanged". if ( 'discount_value' === $input_key && -1.0 === (float) $value ) { continue; } // Special handling for status: convert string to boolean. if ( 'status' === $input_key ) { $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN ); } // Validate product exists if being changed. if ( 'product' === $input_key && $value > 0 ) { $product_obj = wc_get_product( $value ); if ( ! $product_obj ) { throw new Exception( esc_html__( 'Product not found.', 'cartflows-pro' ) ); } } $order_bumps[ $found_index ][ $ob_key ] = $value; $fields_set[] = $input_key; } if ( ! empty( $fields_set ) ) { update_post_meta( $step_id, 'wcf-order-bumps', $order_bumps ); } return array( 'step_id' => $step_id, 'ob_id' => $ob_id, 'updated' => ! empty( $fields_set ), 'fields_set' => $fields_set, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/toggle-order-bump * * Enables or disables an order bump on a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Toggle result or error. * @throws Exception On Failure. */ public function toggle_order_bump( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'toggle-order-bump' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $ob_id = $this->input_get( 'ob_id' ); $enabled = $this->input_get( 'enabled' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $order_bumps = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-bumps' ); if ( ! is_array( $order_bumps ) ) { throw new Exception( esc_html__( 'No order bumps found on this step.', 'cartflows-pro' ) ); } $found_index = null; foreach ( $order_bumps as $index => $ob ) { if ( isset( $ob['id'] ) && (string) $ob['id'] === $ob_id ) { $found_index = $index; break; } } if ( null === $found_index ) { throw new Exception( esc_html__( 'Order bump not found.', 'cartflows-pro' ) ); } $order_bumps[ $found_index ]['status'] = $enabled; update_post_meta( $step_id, 'wcf-order-bumps', $order_bumps ); $message = $enabled ? __( 'Order bump enabled.', 'cartflows-pro' ) : __( 'Order bump disabled.', 'cartflows-pro' ); return array( 'step_id' => $step_id, 'ob_id' => $ob_id, 'is_enabled' => $enabled, 'message' => $message, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-pre-checkout-offer * * Returns the pre-checkout offer configuration for a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Pre-checkout offer config or error. * @throws Exception On Failure. */ public function get_pre_checkout_offer( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-pre-checkout-offer' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer' ); $product_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-product' ); $product_id = ( is_array( $product_raw ) && ! empty( $product_raw[0] ) ) ? intval( $product_raw[0] ) : 0; $product_name = ''; if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); $product_name = $product_obj ? $product_obj->get_name() : ''; } $discount_type = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-discount' ); $discount_value = floatval( wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-discount-value' ) ); return array( 'step_id' => $step_id, 'enabled' => $enabled, 'product_id' => $product_id, 'product_name' => esc_html( $product_name ), 'discount_type' => esc_html( $discount_type ), 'discount_value' => $discount_value, 'popup_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-popup-title' ) ), 'popup_sub_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-popup-sub-title' ) ), 'product_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-product-title' ) ), 'description' => wp_kses_post( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-desc' ) ), 'button_text' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-popup-btn-text' ) ), 'skip_button_text' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-popup-skip-btn-text' ) ), 'bg_color' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-bg-color' ) ), 'title_color' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer-title-color' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/update-pre-checkout-offer * * Updates the pre-checkout offer configuration for a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Update result or error. * @throws Exception On Failure. */ public function update_pre_checkout_offer( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'update-pre-checkout-offer' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } // Map of input field => meta key. $meta_map = array( 'enabled' => 'wcf-pre-checkout-offer', 'discount_type' => 'wcf-pre-checkout-offer-discount', 'popup_title' => 'wcf-pre-checkout-offer-popup-title', 'popup_sub_title' => 'wcf-pre-checkout-offer-popup-sub-title', 'product_title' => 'wcf-pre-checkout-offer-product-title', 'description' => 'wcf-pre-checkout-offer-desc', 'button_text' => 'wcf-pre-checkout-offer-popup-btn-text', 'skip_button_text' => 'wcf-pre-checkout-offer-popup-skip-btn-text', ); $fields_set = array(); foreach ( $meta_map as $input_key => $meta_key ) { $value = $this->input_get( $input_key ); if ( '__unchanged__' === $value ) { continue; } update_post_meta( $step_id, $meta_key, $value ); $fields_set[] = $input_key; } // Handle product_id separately (stored as array). $product_id = $this->input_get( 'product_id' ); if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); if ( ! $product_obj ) { throw new Exception( esc_html__( 'Product not found.', 'cartflows-pro' ) ); } update_post_meta( $step_id, 'wcf-pre-checkout-offer-product', array( $product_id ) ); $fields_set[] = 'product_id'; } // Handle discount_value separately (sentinel -1 = unchanged). $discount_value = $this->input_get( 'discount_value' ); if ( -1.0 !== (float) $discount_value ) { update_post_meta( $step_id, 'wcf-pre-checkout-offer-discount-value', $discount_value ); $fields_set[] = 'discount_value'; } return array( 'step_id' => $step_id, 'updated' => ! empty( $fields_set ), 'fields_set' => $fields_set, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-checkout-product-options * * Returns the product options configuration for a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Product options config or error. * @throws Exception On Failure. */ public function get_checkout_product_options( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-checkout-product-options' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-enable-product-options' ); $selection_mode = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-options' ); $title = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-opt-title' ); $position = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-your-products-position' ); $skin = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-options-skin' ); $variation_on = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-enable-product-variation' ); $variation_mode = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-variation-options' ); $quantity_on = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-enable-product-quantity' ); $images_on = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-show-product-images' ); // Get the checkout products from the step meta. $checkout_products = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-products' ); $product_options_data = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-options-data' ); $products = array(); if ( is_array( $checkout_products ) && ! empty( $checkout_products ) ) { foreach ( $checkout_products as $cp ) { $pid = isset( $cp['product'] ) ? intval( $cp['product'] ) : 0; $name = ''; if ( $pid > 0 ) { $prod_obj = wc_get_product( $pid ); $name = $prod_obj ? $prod_obj->get_name() : ''; } $unique_id = isset( $cp['unique_id'] ) ? (string) $cp['unique_id'] : ''; $po_data = ( is_array( $product_options_data ) && isset( $product_options_data[ $unique_id ] ) ) ? $product_options_data[ $unique_id ] : array(); $products[] = array( 'product_id' => $pid, 'product_name' => esc_html( $name ), 'quantity' => isset( $cp['quantity'] ) ? intval( $cp['quantity'] ) : 1, 'discount_type' => isset( $po_data['discount_type'] ) ? esc_html( (string) $po_data['discount_type'] ) : '', 'discount_value' => isset( $po_data['discount_value'] ) ? floatval( $po_data['discount_value'] ) : 0, 'unique_id' => esc_html( $unique_id ), ); } } return array( 'step_id' => $step_id, 'enabled' => $enabled, 'selection_mode' => esc_html( $selection_mode ), 'title' => esc_html( $title ), 'position' => esc_html( $position ), 'skin' => esc_html( $skin ), 'variation_enabled' => $variation_on, 'variation_mode' => esc_html( $variation_mode ), 'quantity_enabled' => $quantity_on, 'images_enabled' => $images_on, 'products' => $products, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-checkout-rules * * Returns the conditional routing rules for a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Rules config or error. * @throws Exception On Failure. */ public function get_checkout_rules( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-checkout-rules' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-rules-option' ); $default_step = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-rules-default-step' ); $rules_raw = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-rules' ); $rule_groups = array(); if ( is_array( $rules_raw ) ) { foreach ( $rules_raw as $group ) { $group_id = isset( $group['group_id'] ) ? (string) $group['group_id'] : ''; $group_step = isset( $group['step_id'] ) ? (string) $group['step_id'] : ''; $rules = array(); if ( isset( $group['rules'] ) && is_array( $group['rules'] ) ) { foreach ( $group['rules'] as $rule ) { $rules[] = array( 'rule_id' => isset( $rule['rule_id'] ) ? esc_html( (string) $rule['rule_id'] ) : '', 'condition' => isset( $rule['condition'] ) ? esc_html( (string) $rule['condition'] ) : '', 'operator' => isset( $rule['operator'] ) ? esc_html( (string) $rule['operator'] ) : '', 'value' => isset( $rule['value'] ) ? esc_html( (string) $rule['value'] ) : '', ); } } $rule_groups[] = array( 'group_id' => esc_html( $group_id ), 'step_id' => esc_html( $group_step ), 'rules' => $rules, ); } } return array( 'step_id' => $step_id, 'enabled' => $enabled, 'default_step' => esc_html( $default_step ), 'rule_groups' => $rule_groups, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-two-step-config * * Returns the two-step checkout layout configuration for a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Two-step config or error. * @throws Exception On Failure. */ public function get_two_step_config( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-two-step-config' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } return array( 'step_id' => $step_id, 'note_enabled' => 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-box-note' ), 'note_text' => wp_kses_post( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-box-note-text' ) ), 'note_text_color' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-box-note-text-color' ) ), 'note_bg_color' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-box-note-bg-color' ) ), 'step_one_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-step-one-title' ) ), 'step_one_sub_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-step-one-sub-title' ) ), 'step_two_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-step-two-title' ) ), 'step_two_sub_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-step-two-sub-title' ) ), 'offer_button_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-offer-button-title' ) ), 'offer_button_sub_title' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-offer-button-sub-title' ) ), 'section_width' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-two-step-section-width' ) ), 'section_border' => esc_html( (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-two-step-section-border' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-checkout-pro-settings * * Returns a unified summary of all Pro-specific settings on a checkout step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Settings summary or error. * @throws Exception On Failure. */ public function get_checkout_pro_settings( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-checkout-pro-settings' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } // Order bumps count. $order_bumps = wcf()->options->get_checkout_meta_value( $step_id, 'wcf-order-bumps' ); $ob_total = 0; $ob_active = 0; if ( is_array( $order_bumps ) ) { $ob_total = count( $order_bumps ); foreach ( $order_bumps as $ob ) { if ( isset( $ob['status'] ) && $ob['status'] ) { ++$ob_active; } } } // Pre-checkout offer. $pco_enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-pre-checkout-offer' ); // Product options. $po_enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-enable-product-options' ); $po_mode = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-product-options' ); // Checkout layout (from free plugin). $layout = (string) wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-layout' ); // Conditional rules. $rules_enabled = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-checkout-rules-option' ); // Browser tab animation. $tab_anim = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-animate-browser-tab' ); // Advance fields. $advance_fields = 'yes' === wcf()->options->get_checkout_meta_value( $step_id, 'wcf-advance-options-fields' ); return array( 'step_id' => $step_id, 'order_bumps_total' => $ob_total, 'order_bumps_active' => $ob_active, 'pre_checkout_offer_enabled' => $pco_enabled, 'product_options_enabled' => $po_enabled, 'product_options_mode' => esc_html( $po_mode ), 'checkout_layout' => esc_html( $layout ), 'conditional_rules_enabled' => $rules_enabled, 'browser_tab_animation' => $tab_anim, 'advance_fields_enabled' => $advance_fields, 'url_edit' => esc_url_raw( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Execute Callbacks — Orders // ============================================================ /** * Execute: cartflows-pro/list-funnel-orders * * Returns a paginated list of all WooCommerce orders associated with a * specific funnel, including main checkout orders and child offer orders. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Orders list or error. * @throws Exception On Failure. */ public function list_funnel_orders( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-funnel-orders' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $flow_id = $this->input_get( 'flow_id' ); $status = $this->input_get( 'status' ); $page = max( 1, $this->input_get( 'page' ) ); $per_page = min( 100, max( 1, $this->input_get( 'per_page' ) ) ); if ( $flow_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow ID.', 'cartflows-pro' ) ); } $offset = ( $page - 1 ) * $per_page; // Query orders: main orders have _wcf_flow_id, offer orders have _cartflows_parent_flow_id. $meta_query = array( 'relation' => 'OR', array( 'key' => '_wcf_flow_id', 'value' => $flow_id, ), array( 'key' => '_cartflows_parent_flow_id', 'value' => $flow_id, ), ); $query_args = array( 'limit' => $per_page, 'offset' => $offset, 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'return' => 'objects', 'paginate' => true, 'orderby' => 'date', 'order' => 'DESC', ); if ( 'any' !== $status ) { $query_args['status'] = $status; } $wc_orders = wc_get_orders( $query_args ); $orders_data = array(); foreach ( $wc_orders->orders as $order ) { $o_id = (int) $order->get_id(); $is_offer = 'yes' === $order->get_meta( '_cartflows_offer' ); $offer_type = $is_offer ? (string) $order->get_meta( '_cartflows_offer_type' ) : ''; $parent_raw = $order->get_meta( '_cartflows_offer_parent_id' ); $parent_id = ! empty( $parent_raw ) ? (int) $parent_raw : 0; $o_date = $order->get_date_created(); $orders_data[] = array( 'order_id' => $o_id, 'order_type' => $is_offer ? 'offer' : 'main', 'offer_type' => esc_html( $offer_type ), 'parent_order_id' => $is_offer ? $parent_id : 0, 'total' => (float) $order->get_total(), 'currency' => esc_html( $order->get_currency() ), 'status' => esc_html( $order->get_status() ), 'date_created' => $o_date ? $o_date->date( 'c' ) : '', 'url_edit' => esc_url_raw( $order->get_edit_order_url() ), ); } $total = (int) $wc_orders->total; $total_pages = (int) $wc_orders->max_num_pages; return array( 'orders' => $orders_data, 'total' => $total, 'total_pages' => $total_pages, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-order-offer-items * * Returns the line items in a WooCommerce order that are CartFlows offer * items (upsell or downsell), with per-item metadata. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Offer items or error. * @throws Exception On Failure. */ public function get_order_offer_items( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-order-offer-items' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $order_id = $this->input_get( 'order_id' ); if ( $order_id <= 0 ) { throw new Exception( esc_html__( 'Invalid order ID.', 'cartflows-pro' ) ); } $order = wc_get_order( $order_id ); if ( ! $order ) { throw new Exception( esc_html__( 'Order not found.', 'cartflows-pro' ) ); } $offer_items = array(); foreach ( $order->get_items() as $item_id => $item ) { $is_upsell = 'yes' === wc_get_order_item_meta( $item_id, '_cartflows_upsell', true ); $is_downsell = 'yes' === wc_get_order_item_meta( $item_id, '_cartflows_downsell', true ); if ( ! $is_upsell && ! $is_downsell ) { continue; } $step_id = (int) wc_get_order_item_meta( $item_id, '_cartflows_step_id', true ); $txn_id = (string) wc_get_order_item_meta( $item_id, '_cartflows_offer_txn_id', true ); $shipping_fee = (float) wc_get_order_item_meta( $item_id, '_cartflows_offer_shipping_fee', true ); $offer_items[] = array( 'item_id' => (int) $item_id, 'product_id' => (int) $item->get_product_id(), 'product_name' => esc_html( $item->get_name() ), 'quantity' => (int) $item->get_quantity(), 'total' => (float) $item->get_total(), 'offer_type' => $is_upsell ? 'upsell' : 'downsell', 'step_id' => $step_id, 'txn_id' => esc_html( $txn_id ), 'shipping_fee' => $shipping_fee, ); } return array( 'order_id' => $order_id, 'offer_items' => $offer_items, 'total_offer_items' => count( $offer_items ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-offer-order-settings * * Returns the global offer order configuration: separate vs merged mode. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Settings or error. * @throws Exception On Failure. */ public function get_offer_order_settings( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-offer-order-settings' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $settings_default = array( 'separate_offer_orders' => 'merged', ); $raw_settings = Cartflows_Helper::get_admin_settings_option( '_cartflows_offer_global_settings', false, false ); if ( is_array( $raw_settings ) && isset( $raw_settings['separate_offer_orders'] ) ) { $mode = $raw_settings['separate_offer_orders']; } else { $mode = $settings_default['separate_offer_orders']; } return array( 'separate_offer_orders' => esc_html( (string) $mode ), ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Execute Callbacks — A/B Test // ============================================================ /** * Execute: cartflows-pro/list-ab-tests * * Returns a list of all A/B tests across funnels, or scoped to a single * funnel. Reads flow meta `wcf-steps` to find steps with ab-test data. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Tests list or error. * @throws Exception On Failure. */ public function list_ab_tests( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-ab-tests' ); $flow_id = $this->input_get( 'flow_id' ); $query_args = array( 'post_type' => CARTFLOWS_FLOW_POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ); if ( $flow_id > 0 ) { $query_args['post__in'] = array( $flow_id ); } $flow_ids = get_posts( $query_args ); $tests = array(); foreach ( $flow_ids as $fid ) { $flow_steps = get_post_meta( $fid, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { continue; } foreach ( $flow_steps as $step_data ) { if ( empty( $step_data['ab-test'] ) ) { continue; } $control_id = (int) $step_data['id']; $variations = isset( $step_data['ab-test-variations'] ) && is_array( $step_data['ab-test-variations'] ) ? $step_data['ab-test-variations'] : array(); $archived = isset( $step_data['ab-test-archived-variations'] ) && is_array( $step_data['ab-test-archived-variations'] ) ? $step_data['ab-test-archived-variations'] : array(); $ab_start = ! empty( $step_data['ab-test-start'] ); $ab_ui = ! empty( $step_data['ab-test-ui'] ); // Determine status. if ( ! $ab_ui && empty( $variations ) ) { $status = 'completed'; } elseif ( $ab_start ) { $status = 'running'; } else { $status = 'not_started'; } $variation_items = array(); foreach ( $variations as $v ) { $variation_items[] = array( 'step_id' => (int) $v['id'], 'traffic' => (int) $v['traffic'], ); } $tests[] = array( 'flow_id' => (int) $fid, 'flow_title' => esc_html( get_the_title( $fid ) ), 'control_step_id' => $control_id, 'control_step_title' => esc_html( get_the_title( $control_id ) ), 'step_type' => esc_html( (string) get_post_meta( $control_id, 'wcf-step-type', true ) ), 'status' => $status, 'variations' => $variation_items, 'archived_count' => count( $archived ), ); } } return array( 'tests' => $tests, 'total' => count( $tests ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-ab-test * * Returns full details of a single A/B test by control step ID. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Test details or error. * @throws Exception On Failure. */ public function get_ab_test( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-ab-test' ); $step_id = $this->input_get( 'step_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step ID.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } // Resolve control step (the step_id might be a variation). $control_raw = get_post_meta( $step_id, 'wcf-control-step', true ); $control_id = ! empty( $control_raw ) ? (int) $control_raw : $step_id; $flow_id = (int) get_post_meta( $control_id, 'wcf-flow-id', true ); $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } $found = false; foreach ( $flow_steps as $step_data ) { if ( (int) $step_data['id'] !== $control_id ) { continue; } if ( empty( $step_data['ab-test'] ) ) { throw new Exception( esc_html__( 'This step does not have an A/B test.', 'cartflows-pro' ) ); } $variations_raw = isset( $step_data['ab-test-variations'] ) && is_array( $step_data['ab-test-variations'] ) ? $step_data['ab-test-variations'] : array(); $archived_raw = isset( $step_data['ab-test-archived-variations'] ) && is_array( $step_data['ab-test-archived-variations'] ) ? $step_data['ab-test-archived-variations'] : array(); $ab_start = ! empty( $step_data['ab-test-start'] ); $ab_ui = ! empty( $step_data['ab-test-ui'] ); if ( ! $ab_ui && empty( $variations_raw ) ) { $status = 'completed'; } elseif ( $ab_start ) { $status = 'running'; } else { $status = 'not_started'; } $variations = array(); foreach ( $variations_raw as $v ) { $variations[] = array( 'step_id' => (int) $v['id'], 'title' => esc_html( get_the_title( (int) $v['id'] ) ), 'traffic' => (int) $v['traffic'], ); } $archived = array(); foreach ( $archived_raw as $av ) { $archived[] = array( 'step_id' => (int) $av['id'], 'title' => esc_html( isset( $av['title'] ) ? $av['title'] : '' ), 'note' => esc_html( isset( $av['note'] ) ? $av['note'] : '' ), 'deleted' => ! empty( $av['deleted'] ), 'date' => esc_html( isset( $av['date'] ) ? $av['date'] : '' ), ); } $found = true; return array( 'flow_id' => $flow_id, 'flow_title' => esc_html( get_the_title( $flow_id ) ), 'control_step_id' => $control_id, 'control_step_title' => esc_html( get_the_title( $control_id ) ), 'step_type' => esc_html( (string) get_post_meta( $control_id, 'wcf-step-type', true ) ), 'status' => $status, 'has_ui' => $ab_ui, 'variations' => $variations, 'archived_variations' => $archived, ); } if ( ! $found ) { throw new Exception( esc_html__( 'A/B test not found for this step.', 'cartflows-pro' ) ); } } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/get-ab-test-settings * * Returns the global A/B test configuration. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Settings or error. * @throws Exception On Failure. */ public function get_ab_test_settings( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'get-ab-test-settings' ); $abtest_settings = Cartflows_Pro_Helper::get_abtest_settings(); return array( 'override_permalink' => esc_html( isset( $abtest_settings['override_permalink'] ) ? $abtest_settings['override_permalink'] : 'disable' ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/update-ab-test-traffic * * Updates traffic distribution for all active variants in an A/B test. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function update_ab_test_traffic( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'update-ab-test-traffic' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); $traffic_raw = $this->input_get( 'traffic' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) ) { throw new Exception( esc_html__( 'Invalid flow.', 'cartflows-pro' ) ); } if ( ! is_array( $traffic_raw ) || empty( $traffic_raw ) ) { throw new Exception( esc_html__( 'Traffic array is required and must not be empty.', 'cartflows-pro' ) ); } // Validate traffic sums to 100. $total_traffic = 0; $traffic_map = array(); foreach ( $traffic_raw as $entry ) { if ( ! isset( $entry['step_id'] ) || ! isset( $entry['traffic'] ) ) { throw new Exception( esc_html__( 'Each traffic entry must have step_id and traffic.', 'cartflows-pro' ) ); } $t_val = (int) $entry['traffic']; if ( $t_val < 0 || $t_val > 100 ) { throw new Exception( esc_html__( 'Traffic values must be between 0 and 100.', 'cartflows-pro' ) ); } $traffic_map[ (int) $entry['step_id'] ] = $t_val; $total_traffic += $t_val; } if ( 100 !== $total_traffic ) { throw new Exception( esc_html__( 'Traffic values must sum to 100.', 'cartflows-pro' ) ); } $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } $updated = false; foreach ( $flow_steps as $index => $step_data ) { if ( (int) $step_data['id'] !== $step_id ) { continue; } if ( empty( $step_data['ab-test'] ) ) { throw new Exception( esc_html__( 'This step does not have an A/B test.', 'cartflows-pro' ) ); } $variations = isset( $step_data['ab-test-variations'] ) && is_array( $step_data['ab-test-variations'] ) ? $step_data['ab-test-variations'] : array(); // Apply traffic map. foreach ( $variations as $v_index => $v ) { $vid = (int) $v['id']; if ( isset( $traffic_map[ $vid ] ) ) { $variations[ $v_index ]['traffic'] = $traffic_map[ $vid ]; } } $flow_steps[ $index ]['ab-test-variations'] = $variations; $updated = true; break; } if ( ! $updated ) { throw new Exception( esc_html__( 'A/B test not found for this step.', 'cartflows-pro' ) ); } update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); // Build response. $result_variations = array(); foreach ( $flow_steps[ $index ]['ab-test-variations'] as $v ) { $result_variations[] = array( 'step_id' => (int) $v['id'], 'traffic' => (int) $v['traffic'], ); } return array( 'success' => true, 'variations' => $result_variations, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/start-ab-test * * Toggles an A/B test between running and stopped. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function start_ab_test( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'start-ab-test' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) || CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) { throw new Exception( esc_html__( 'Invalid flow or step.', 'cartflows-pro' ) ); } $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } $toggled = false; $is_running = false; foreach ( $flow_steps as $index => $step_data ) { if ( (int) $step_data['id'] !== $step_id ) { continue; } if ( empty( $step_data['ab-test'] ) ) { throw new Exception( esc_html__( 'This step does not have an A/B test.', 'cartflows-pro' ) ); } // Toggle. $current_start = ! empty( $step_data['ab-test-start'] ); $is_running = ! $current_start; $flow_steps[ $index ]['ab-test-start'] = $is_running; $toggled = true; break; } if ( ! $toggled ) { throw new Exception( esc_html__( 'A/B test not found for this step.', 'cartflows-pro' ) ); } update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); $message = $is_running ? __( 'A/B test started.', 'cartflows-pro' ) : __( 'A/B test stopped.', 'cartflows-pro' ); return array( 'success' => true, 'running' => $is_running, 'message' => $message, ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/declare-ab-test-winner * * Declares a variation as the A/B test winner. Archives all losers. * Mirrors the logic in Cartflows_Pro_Ab_Test_Meta::declare_ab_test_winner(). * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function declare_ab_test_winner( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'declare-ab-test-winner' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) || CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) { throw new Exception( esc_html__( 'Invalid flow or step.', 'cartflows-pro' ) ); } $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } // Resolve control step. $control_raw = get_post_meta( $step_id, 'wcf-control-step', true ); $control_step = empty( $control_raw ) ? $step_id : (int) $control_raw; $declared = false; $archived_ids = array(); foreach ( $flow_steps as $index => $data ) { if ( (int) $data['id'] !== $control_step ) { continue; } if ( empty( $data['ab-test'] ) ) { throw new Exception( esc_html__( 'This step does not have an A/B test.', 'cartflows-pro' ) ); } $all_variations = isset( $data['ab-test-variations'] ) && is_array( $data['ab-test-variations'] ) ? $data['ab-test-variations'] : array(); $archived_variations = isset( $data['ab-test-archived-variations'] ) && is_array( $data['ab-test-archived-variations'] ) ? $data['ab-test-archived-variations'] : array(); // Update winner step in flow. $flow_steps[ $index ] = array( 'id' => (int) $step_id, 'title' => get_the_title( $step_id ), 'type' => get_post_meta( $step_id, 'wcf-step-type', true ), ); // Remove ab test meta from winner. delete_post_meta( $step_id, 'wcf-ab-test' ); delete_post_meta( $step_id, 'wcf-control-step' ); do_action( 'cartflows_ab_test_update_control', $flow_id, $flow_steps[ $index ] ); // Update winner in already archived variations. foreach ( $archived_variations as $av_data ) { if ( $step_id !== $av_data['id'] ) { update_post_meta( $av_data['id'], 'wcf-winner-step', $step_id ); } } // Archive all other active variations. $current_time = current_time( 'Y-m-d H:i:s' ); foreach ( $all_variations as $v_data ) { if ( $step_id !== $v_data['id'] ) { $step_to_archive = array( 'id' => $v_data['id'], 'title' => get_the_title( $v_data['id'] ), 'note' => get_post_meta( $v_data['id'], 'wcf-step-note', true ), 'deleted' => false, 'raw_date' => $current_time, 'date' => gmdate( 'M d, Y', strtotime( $current_time ) ), ); array_unshift( $archived_variations, $step_to_archive ); update_post_meta( $v_data['id'], 'wcf-winner-step', $step_id ); delete_post_meta( $v_data['id'], 'wcf-ab-test' ); $archived_ids[] = (int) $v_data['id']; } } // Keep ab step data for analytics. $flow_steps[ $index ]['ab-test'] = true; $flow_steps[ $index ]['ab-test-ui'] = false; $flow_steps[ $index ]['ab-test-start'] = false; $flow_steps[ $index ]['ab-test-variations'] = array(); $flow_steps[ $index ]['ab-test-archived-variations'] = $archived_variations; $declared = true; break; } if ( ! $declared ) { throw new Exception( esc_html__( 'A/B test not found for this step.', 'cartflows-pro' ) ); } // Re-index and save. $flow_steps = array_values( $flow_steps ); update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); return array( 'success' => true, 'winner_id' => $step_id, 'archived' => $archived_ids, /* translators: %d step ID */ 'message' => sprintf( __( 'Step %d declared as winner. Losers archived.', 'cartflows-pro' ), $step_id ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/create-ab-test-variation * * Creates a new A/B test variation by cloning the step. * Mirrors the logic in Cartflows_Pro_Ab_Test_Meta::create_ab_test_variation(). * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function create_ab_test_variation( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'create-ab-test-variation' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) || CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) { throw new Exception( esc_html__( 'Invalid flow or step.', 'cartflows-pro' ) ); } // Enable ab test on the step. update_post_meta( $step_id, 'wcf-ab-test', true ); // Clone the step. $new_step_id = wcf_pro()->utils->clone_step( $step_id ); if ( ! $new_step_id ) { throw new Exception( esc_html__( 'Failed to clone the step.', 'cartflows-pro' ) ); } // Set control step on the new variation. update_post_meta( $new_step_id, 'wcf-control-step', $step_id ); $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } $created = false; $variations = array(); foreach ( $flow_steps as $index => $step_data ) { if ( (int) $step_data['id'] !== $step_id ) { continue; } // Check if this is the first variation or an additional one. $existing_variations = isset( $step_data['ab-test-variations'] ) && is_array( $step_data['ab-test-variations'] ) ? $step_data['ab-test-variations'] : array(); if ( empty( $existing_variations ) ) { // First variation: set up 50/50. $variations = array( array( 'id' => $step_id, 'traffic' => 50, ), array( 'id' => $new_step_id, 'traffic' => 50, ), ); } else { // Additional variation: add at 0% traffic. $existing_variations[] = array( 'id' => $new_step_id, 'traffic' => 0, ); $variations = $existing_variations; } $flow_steps[ $index ]['ab-test'] = true; $flow_steps[ $index ]['ab-test-ui'] = true; $flow_steps[ $index ]['ab-test-variations'] = $variations; if ( ! isset( $flow_steps[ $index ]['ab-test-start'] ) ) { $flow_steps[ $index ]['ab-test-start'] = false; } if ( ! isset( $flow_steps[ $index ]['ab-test-archived-variations'] ) ) { $flow_steps[ $index ]['ab-test-archived-variations'] = array(); } $created = true; break; } if ( ! $created ) { throw new Exception( esc_html__( 'Step not found in flow.', 'cartflows-pro' ) ); } update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); $result_variations = array(); foreach ( $variations as $v ) { $result_variations[] = array( 'step_id' => (int) $v['id'], 'traffic' => (int) $v['traffic'], ); } return array( 'success' => true, 'new_variation_id' => $new_step_id, 'variations' => $result_variations, /* translators: %d new step ID */ 'message' => sprintf( __( 'Variation %d created.', 'cartflows-pro' ), $new_step_id ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/archive-ab-test-variation * * Archives (soft-removes) a variation from an active A/B test. * Delegates to the existing delete_variation() method with $delete_data = false. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function archive_ab_test_variation( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'archive-ab-test-variation' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) || CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) { throw new Exception( esc_html__( 'Invalid flow or step.', 'cartflows-pro' ) ); } // Use the existing method for archiving (delete_data = false means archive only). $ab_test_meta = Cartflows_Pro_Ab_Test_Meta::get_instance(); $ab_test_meta->delete_variation( $flow_id, $step_id, false ); return array( 'success' => true, /* translators: %d step ID */ 'message' => sprintf( __( 'Variation %d archived.', 'cartflows-pro' ), $step_id ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/restore-ab-test-variation * * Restores an archived variation back into the active A/B test. * Mirrors the logic in Cartflows_Pro_Ab_Test_Meta::restore_archive_ab_test_variation(). * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Result or error. * @throws Exception On Failure. */ public function restore_ab_test_variation( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'restore-ab-test-variation' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); $control_id = $this->input_get( 'control_id' ); if ( $flow_id <= 0 || $step_id <= 0 || $control_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id, step_id, or control_id.', 'cartflows-pro' ) ); } if ( CARTFLOWS_FLOW_POST_TYPE !== get_post_type( $flow_id ) || CARTFLOWS_STEP_POST_TYPE !== get_post_type( $step_id ) ) { throw new Exception( esc_html__( 'Invalid flow or step.', 'cartflows-pro' ) ); } $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } // Update control id on the step. update_post_meta( $step_id, 'wcf-control-step', $control_id ); // Mark step as part of ab test again. update_post_meta( $step_id, 'wcf-ab-test', true ); $restored = false; foreach ( $flow_steps as $index => $data ) { if ( (int) $data['id'] !== $control_id ) { continue; } $all_variations = isset( $data['ab-test-variations'] ) && is_array( $data['ab-test-variations'] ) ? $data['ab-test-variations'] : array(); $archived_variations = isset( $data['ab-test-archived-variations'] ) && is_array( $data['ab-test-archived-variations'] ) ? $data['ab-test-archived-variations'] : array(); // Add to active variations at 0% traffic. $all_variations[] = array( 'id' => $step_id, 'traffic' => 0, ); // Remove from archived list. foreach ( $archived_variations as $v_index => $v_data ) { if ( (int) $v_data['id'] === $step_id ) { array_splice( $archived_variations, $v_index, 1 ); delete_post_meta( $step_id, 'wcf-winner-step' ); break; } } $flow_steps[ $index ]['ab-test-variations'] = $all_variations; $flow_steps[ $index ]['ab-test-archived-variations'] = $archived_variations; $restored = true; break; } if ( ! $restored ) { throw new Exception( esc_html__( 'A/B test not found for the control step.', 'cartflows-pro' ) ); } // Re-index and save. $flow_steps = array_values( $flow_steps ); update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); return array( 'success' => true, /* translators: %d step ID */ 'message' => sprintf( __( 'Variation %d restored to active test with 0%% traffic.', 'cartflows-pro' ), $step_id ), ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Execute Callbacks — Gateways (read-only) // ============================================================ /** * Execute: cartflows-pro/list-supported-gateways * * Returns all payment gateways that CartFlows Pro supports for offers, * cross-referenced with WooCommerce's active gateways. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Gateways list or error. * @throws Exception On Failure. */ public function list_supported_gateways( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'list-supported-gateways' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $gateways_instance = Cartflows_Pro_Gateways::get_instance(); $supported_gateways = $gateways_instance->get_supported_gateways(); $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); $available_keys = is_array( $available_gateways ) ? array_keys( $available_gateways ) : array(); $gateways = array(); $total_available = 0; foreach ( $supported_gateways as $key => $gateway_data ) { $is_available = in_array( $key, $available_keys, true ); $label = ''; if ( $is_available && isset( $available_gateways[ $key ] ) && ! empty( $available_gateways[ $key ]->method_title ) ) { $label = $available_gateways[ $key ]->method_title; } if ( $is_available ) { ++$total_available; } $gateways[] = array( 'key' => esc_html( $key ), 'label' => esc_html( $label ), 'is_available' => $is_available, ); } return array( 'gateways' => $gateways, 'total_supported' => count( $supported_gateways ), 'total_available' => $total_available, ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Execute Callbacks — Creation Abilities // ============================================================ /** * Execute: cartflows-pro/create-step * * Creates a new upsell or downsell step in a funnel. Rejects free step types * (landing, checkout, optin, thankyou) — those belong to cartflows/create-step. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Created step data or error. * @throws Exception On Failure. */ public function create_step( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'create-step' ); $flow_id = $this->input_get( 'flow_id' ); $title = $this->input_get( 'title' ); $type = $this->input_get( 'type' ); if ( $flow_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id.', 'cartflows-pro' ) ); } $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } $pro_types = array( 'upsell', 'downsell' ); if ( ! in_array( $type, $pro_types, true ) ) { throw new Exception( esc_html__( 'Use the cartflows/create-step ability for free step types.', 'cartflows-pro' ) ); } $step_id = wp_insert_post( array( 'post_type' => CARTFLOWS_STEP_POST_TYPE, 'post_title' => sanitize_text_field( $title ), 'post_status' => 'publish', ) ); if ( is_wp_error( $step_id ) || ! $step_id ) { throw new Exception( esc_html__( 'Failed to create step.', 'cartflows-pro' ) ); } // Set step meta. update_post_meta( $step_id, 'wcf-flow-id', $flow_id ); update_post_meta( $step_id, 'wcf-step-type', $type ); // Set taxonomy terms. wp_set_object_terms( $step_id, $type, CARTFLOWS_TAXONOMY_STEP_TYPE ); wp_set_object_terms( $step_id, 'flow-' . $flow_id, CARTFLOWS_TAXONOMY_STEP_FLOW ); // Append to flow's wcf-steps meta. $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { $flow_steps = array(); } $flow_steps[] = array( 'id' => $step_id, 'title' => sanitize_text_field( $title ), 'type' => $type, ); update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); return array( 'step_id' => $step_id, 'flow_id' => $flow_id, 'title' => esc_html( get_the_title( $step_id ) ), 'type' => esc_html( $type ), 'edit_url' => esc_url( get_edit_post_link( $step_id, 'raw' ) ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/create-order-bump * * Creates a new order bump on a checkout step with default settings. * Optionally assigns a WooCommerce product. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Created order bump data or error. * @throws Exception On Failure. */ public function create_order_bump( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'create-order-bump' ); if ( ! function_exists( 'WC' ) ) { throw new Exception( esc_html__( 'WooCommerce is not active.', 'cartflows-pro' ) ); } $step_id = $this->input_get( 'step_id' ); $title = $this->input_get( 'title' ); $product_id = $this->input_get( 'product_id' ); if ( $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid step_id.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } $step_type = get_post_meta( $step_id, 'wcf-step-type', true ); if ( 'checkout' !== $step_type ) { throw new Exception( esc_html__( 'Step is not a checkout step. Order bumps can only be added to checkout steps.', 'cartflows-pro' ) ); } // Generate unique OB ID. $ob_id = substr( md5( wp_rand() ), 2, 3 ); // Build order bump from defaults using the existing helper. $order_bumps = get_post_meta( $step_id, 'wcf-order-bumps', true ); if ( ! is_array( $order_bumps ) ) { $order_bumps = array(); } $default_meta = Cartflows_Pro_Checkout_Default_Meta::get_instance()->order_bump_default_meta(); $new_ob = array(); foreach ( $default_meta as $key => $value ) { $new_ob[ $key ] = $value['default']; } // Set dynamic values. $new_ob['id'] = $ob_id; $new_ob['title'] = sanitize_text_field( $title ); if ( $product_id > 0 ) { $product_obj = wc_get_product( $product_id ); if ( ! $product_obj ) { throw new Exception( esc_html__( 'Product not found.', 'cartflows-pro' ) ); } $new_ob['product'] = $product_id; } $order_bumps[] = $new_ob; update_post_meta( $step_id, 'wcf-order-bumps', $order_bumps ); return array( 'ob_id' => esc_html( $ob_id ), 'step_id' => $step_id, 'title' => esc_html( $new_ob['title'] ), 'product_id' => (int) $new_ob['product'], 'status' => (bool) $new_ob['status'], 'position' => esc_html( $new_ob['position'] ), 'style' => esc_html( $new_ob['style'] ), ); } catch ( Exception $e ) { return $this->error( $e ); } } /** * Execute: cartflows-pro/create-ab-test * * Sets up a new A/B split test for a step. The original step becomes the * control, a clone is created as the variation, and traffic is split 50/50. * * @since 2.2.2 * @param array $input Raw input from Abilities API. * @return array|WP_Error Created A/B test data or error. * @throws Exception On Failure. */ public function create_ab_test( $input ) { try { $this->init( $input, CARTFLOWS_PRO_ABILITY_API_NAMESPACE . 'create-ab-test' ); $flow_id = $this->input_get( 'flow_id' ); $step_id = $this->input_get( 'step_id' ); if ( $flow_id <= 0 || $step_id <= 0 ) { throw new Exception( esc_html__( 'Invalid flow_id or step_id.', 'cartflows-pro' ) ); } $flow_post = get_post( $flow_id ); if ( ! $flow_post || CARTFLOWS_FLOW_POST_TYPE !== $flow_post->post_type ) { throw new Exception( esc_html__( 'Flow not found.', 'cartflows-pro' ) ); } $step_post = get_post( $step_id ); if ( ! $step_post || CARTFLOWS_STEP_POST_TYPE !== $step_post->post_type ) { throw new Exception( esc_html__( 'Step not found.', 'cartflows-pro' ) ); } // Verify step belongs to this flow. $step_flow_id = (int) get_post_meta( $step_id, 'wcf-flow-id', true ); if ( $step_flow_id !== $flow_id ) { throw new Exception( esc_html__( 'Step does not belong to this flow.', 'cartflows-pro' ) ); } // Check step doesn't already have an A/B test. $existing_ab = get_post_meta( $step_id, 'wcf-ab-test', true ); if ( $existing_ab ) { throw new Exception( esc_html__( 'Step already has an A/B test configured.', 'cartflows-pro' ) ); } // Enable A/B test flag on the control step. update_post_meta( $step_id, 'wcf-ab-test', true ); // Clone the step to create the variation. $variation_id = wcf_pro()->utils->clone_step( $step_id ); if ( ! $variation_id ) { throw new Exception( esc_html__( 'Failed to clone the step.', 'cartflows-pro' ) ); } // Set control reference on the variation. update_post_meta( $variation_id, 'wcf-control-step', $step_id ); // Build variations array with 50/50 split. $variations = array( array( 'id' => $step_id, 'traffic' => 50, ), array( 'id' => $variation_id, 'traffic' => 50, ), ); // Update flow steps meta with A/B test data. $flow_steps = get_post_meta( $flow_id, 'wcf-steps', true ); if ( ! is_array( $flow_steps ) ) { throw new Exception( esc_html__( 'Flow steps not found.', 'cartflows-pro' ) ); } $updated = false; foreach ( $flow_steps as $index => $step_data ) { if ( (int) $step_data['id'] !== $step_id ) { continue; } $flow_steps[ $index ]['ab-test'] = true; $flow_steps[ $index ]['ab-test-ui'] = true; $flow_steps[ $index ]['ab-test-start'] = false; $flow_steps[ $index ]['ab-test-variations'] = $variations; $flow_steps[ $index ]['ab-test-archived-variations'] = array(); $updated = true; break; } if ( ! $updated ) { throw new Exception( esc_html__( 'Step not found in flow steps.', 'cartflows-pro' ) ); } update_post_meta( $flow_id, 'wcf-steps', $flow_steps ); return array( 'control_id' => $step_id, 'variation_id' => $variation_id, 'variation_title' => esc_html( get_the_title( $variation_id ) ), 'traffic_split' => array( 50, 50 ), 'started' => false, ); } catch ( Exception $e ) { return $this->error( $e ); } } // ============================================================ // Helper Methods (Base) // ============================================================ /** * Initialize input parsing for an ability execution. * * @since 2.2.2 * @param array $input Raw input. * @param string $ability_name Ability identifier. * @return void */ public function init( $input, $ability_name ) { $this->input_parse( $input, $ability_name ); } /** * Check user capabilities. * * Returns true only when the current user has ALL requested capabilities. * Returns false for unauthenticated requests (user ID = 0). * * @since 2.2.2 * @param string|array $caps Single capability string or array of capability strings (AND logic). * @return bool */ public function permission_callback( $caps ) { if ( empty( $caps ) ) { return false; } $user = wp_get_current_user(); if ( ! $user || 0 === $user->ID ) { return false; } if ( is_string( $caps ) ) { return $user->has_cap( $caps ); } if ( is_array( $caps ) ) { foreach ( $caps as $cap ) { if ( ! $user->has_cap( $cap ) ) { return false; } } return true; } return false; } /** * Parse and validate input against the ability's input schema. * * - Checks required fields (before applying defaults) * - Applies defaults for missing optional fields * - Coerces values to declared schema types * - Sanitizes string values * - Validates enum constraints * * @since 2.2.2 * @param array $input Raw input array (or WP_REST_Request). * @param string $ability_name Ability identifier. * @return array Parsed input. * @throws Exception When a required field is missing or enum validation fails. */ public function input_parse( $input, $ability_name ) { $this->input = array(); // Handle WP_REST_Request. if ( is_a( $input, 'WP_REST_Request' ) ) { $input = $input->get_json_params(); if ( ! is_array( $input ) ) { $input = array(); } } if ( ! is_array( $input ) ) { $input = array(); } $input_schema = Cartflows_Pro_Ability_Config::get_ability_input_schema( $ability_name ); if ( ! is_array( $input_schema ) || empty( $input_schema ) ) { return array(); } if ( ! isset( $input_schema['properties'] ) || ! is_array( $input_schema['properties'] ) ) { return array(); } // Required fields — JSON Schema standard: array at the object level. $required_fields = ( isset( $input_schema['required'] ) && is_array( $input_schema['required'] ) ) ? $input_schema['required'] : array(); foreach ( $input_schema['properties'] as $name => $prop ) { $type = isset( $prop['type'] ) ? strtolower( $prop['type'] ) : 'string'; $raw_value = array_key_exists( $name, $input ) ? $input[ $name ] : null; // Required check runs BEFORE defaults. $is_required = in_array( $name, $required_fields, true ); if ( $is_required && ( null === $raw_value || '' === $raw_value ) ) { throw new Exception( sprintf( /* translators: %s: field name */ esc_html__( 'Required field %s is missing.', 'cartflows-pro' ), esc_html( $name ) ) ); } // Apply schema default if value not provided. if ( null === $raw_value && isset( $prop['default'] ) ) { $raw_value = $prop['default']; } // Fall back to type-appropriate empty value. if ( null === $raw_value ) { switch ( $type ) { case 'integer': $raw_value = 0; break; case 'number': $raw_value = 0.0; break; case 'boolean': $raw_value = false; break; case 'array': $raw_value = array(); break; case 'object': $raw_value = array(); break; default: $raw_value = ''; break; } } // Type coercion and sanitization. $value = $raw_value; switch ( $type ) { case 'integer': $value = intval( $value ); break; case 'number': $value = floatval( $value ); break; case 'boolean': $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN ); break; case 'string': $value = is_string( $value ) ? sanitize_text_field( $value ) : sanitize_text_field( strval( $value ) ); break; case 'array': if ( ! is_array( $value ) ) { $value = array(); } $value = $this->sanitize_recursive( $value ); break; case 'object': if ( ! is_array( $value ) && ! is_object( $value ) ) { $value = array(); } if ( is_object( $value ) ) { $value = (array) $value; } $value = $this->sanitize_recursive( $value ); break; } // Enum validation (strict comparison after coercion). if ( isset( $prop['enum'] ) && is_array( $prop['enum'] ) ) { if ( ! in_array( $value, $prop['enum'], true ) ) { throw new Exception( sprintf( /* translators: %s: field name */ esc_html__( 'Invalid value for %s.', 'cartflows-pro' ), esc_html( $name ) ) ); } } $this->input[ $name ] = $value; } return $this->input; } /** * Get a parsed input value by property name. * * Uses a sentinel default pattern to distinguish "no default provided" from null. * * @since 2.2.2 * @param string $name Property name. * @param mixed $default Default value. Omit to throw when property not found. * @return mixed * @throws Exception When input has not been parsed or property is not found and no default given. */ public function input_get( $name, $default = '__CARTFLOWS_PRO_NO_DEFAULT__' ) { if ( false === $this->input ) { throw new Exception( esc_html__( 'Inputs not parsed.', 'cartflows-pro' ) ); } if ( ! array_key_exists( $name, $this->input ) ) { if ( '__CARTFLOWS_PRO_NO_DEFAULT__' !== $default ) { return $default; } throw new Exception( sprintf( /* translators: %s: property name */ esc_html__( 'Property %s not found in parsed input.', 'cartflows-pro' ), esc_html( $name ) ) ); } return $this->input[ $name ]; } /** * Recursively sanitize array/object values. * * Applies `sanitize_text_field` to strings and type-casts scalars. * * @since 2.2.2 * @param array $data Data to sanitize. * @return array Sanitized data. */ protected function sanitize_recursive( $data ) { if ( ! is_array( $data ) ) { return $data; } $sanitized = array(); foreach ( $data as $key => $value ) { $key = sanitize_text_field( strval( $key ) ); if ( is_array( $value ) ) { $sanitized[ $key ] = $this->sanitize_recursive( $value ); } elseif ( is_string( $value ) ) { $sanitized[ $key ] = sanitize_text_field( $value ); } elseif ( is_int( $value ) ) { $sanitized[ $key ] = intval( $value ); } elseif ( is_float( $value ) ) { $sanitized[ $key ] = floatval( $value ); } elseif ( is_bool( $value ) ) { $sanitized[ $key ] = (bool) $value; } else { $sanitized[ $key ] = $value; } } return $sanitized; } /** * Format a structured error response. * * Raw debug data (file and line) is only exposed when WP_DEBUG is true. * * @since 2.2.2 * @param Exception $e The exception. * @return array Structured error array. */ public function error( $e ) { $error = array( 'error' => array( 'code' => 'cartflows_pro_error', 'message' => esc_html( $e->getMessage() ), ), ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { $error['error']['debug'] = array( 'file' => $e->getFile(), 'line' => $e->getLine(), ); } return $error; } }