`. */ public static function shop_id() { $slug = Xpay_Plugin::merchant_slug(); return '' === $slug ? '' : 'woo-' . $slug; } /** Hard-off constant, then connection. Gates click capture and everything after. */ private static function connected() { if ( defined( 'XPAY_WC_CHATGPT_PIXEL' ) && false === XPAY_WC_CHATGPT_PIXEL ) { return false; } return Xpay_Plugin::is_connected() && '' !== Xpay_Plugin::api_key(); } /** * Should the storefront pixel and the order backup run? * * @return bool */ public static function is_active() { if ( ! self::connected() ) { return false; } $mode = (string) get_option( self::OPTION_ENABLED, 'auto' ); if ( '0' === $mode ) { $on = false; } elseif ( '1' === $mode ) { $on = true; } else { $config = self::config(); $on = ! empty( $config['pixel_id'] ); } /** * Filter whether the ChatGPT Ads pixel runs on this store. * * @param bool $on Resolved state. */ return (bool) apply_filters( 'xpay_wc_chatgpt_pixel_active', $on ); } /** * Is another OpenAI pixel already on this storefront? * * Reads the CACHED detection only — a storefront render must never wait on * the loopback scan. A cache miss schedules a background refresh and reads as * "none found" until it lands. * * @return bool */ public static function other_pixel_present() { $d = get_transient( Xpay_Pixel_Detect::CACHE_KEY ); if ( ! is_array( $d ) ) { if ( ! wp_next_scheduled( self::DETECT_HOOK ) ) { wp_schedule_single_event( time(), self::DETECT_HOOK ); } return false; } if ( ! empty( $d['html'] ) ) { return true; } // A dedicated OpenAI pixel plugin is a pixel whether or not we saw it fire. return ! empty( $d['plugins'] ) && in_array( 'Measurement Pixel for OpenAI Ads', (array) $d['plugins'], true ); } /** Background detection refresh (cron). */ public static function refresh_detection() { Xpay_Pixel_Detect::detect( true ); } /** * Forward our events to OpenAI, or record them only? * * @return bool */ public static function should_forward() { $mode = (string) get_option( self::OPTION_FORWARD, 'auto' ); if ( 'on' === $mode ) { $fwd = true; } elseif ( 'off' === $mode ) { $fwd = false; } else { $fwd = ! self::other_pixel_present(); } /** * Filter whether pixel events may be forwarded to OpenAI. * * @param bool $fwd Resolved state. */ return (bool) apply_filters( 'xpay_wc_pixel_forward', $fwd ); } /** * Default consent when no consent platform answers. * * With the WP Consent API present, the browser asks it directly; this is the * fallback for every store without a CMP. EU/EEA/UK/CH stores default to no * consent, everyone else to consent — the same posture as the attribution * cookie and the Shopify relay's backup. * * @return bool */ public static function default_consent() { $base = ''; if ( function_exists( 'WC' ) && WC()->countries ) { $base = strtoupper( (string) WC()->countries->get_base_country() ); } $consent = ! in_array( $base, explode( ' ', self::CONSENT_REGIONS ), true ); /** * Filter the default marketing-consent decision for the ChatGPT Ads pixel. * * @param bool $consent Resolved default. * @param string $base Store base country. */ return (bool) apply_filters( 'xpay_wc_pixel_consent', $consent, $base ); } /** * Consent for THIS request (a shopper's request — never a cron job). * * @return bool */ private static function request_consent() { if ( function_exists( 'wp_has_consent' ) ) { return (bool) wp_has_consent( 'marketing' ); } return self::default_consent(); } // ─── relay config ───────────────────────────────────────────────────── /** * Relay config for this store. Never blocks a storefront render: a cold cache * returns the last known good copy and refreshes in the background. * * @param bool $force Fetch now (admin screens only). * @return array */ public static function config( $force = false ) { if ( ! $force ) { $cached = get_transient( self::CONFIG_TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } if ( ! is_admin() || wp_doing_ajax() ) { $lkg = get_option( self::CONFIG_LKG ); // Background refresh ONLY while tracking is on (a pixel is linked). // A store that never set tracking up makes no call to the relay // from shopper traffic; it learns of a new link when the merchant // opens the Ads screen, or when our backend pushes the // `refresh_chatgpt_pixel` admin action after linking one for them. if ( is_array( $lkg ) && ! empty( $lkg['pixel_id'] ) && ! wp_next_scheduled( self::CONFIG_HOOK ) ) { wp_schedule_single_event( time(), self::CONFIG_HOOK ); } return is_array( $lkg ) ? $lkg : self::empty_config(); } } return self::refresh_config(); } /** * Fetch `GET /api/woo/config` and cache it. * * @return array */ public static function refresh_config() { if ( ! self::connected() ) { return self::empty_config(); } $res = self::relay( 'GET', '/api/woo/config' ); if ( is_wp_error( $res ) || ! is_array( $res ) ) { // Keep serving what we last knew; retry in a few minutes. $lkg = get_option( self::CONFIG_LKG ); $out = is_array( $lkg ) ? $lkg : self::empty_config(); set_transient( self::CONFIG_TRANSIENT, $out, self::CONFIG_FAIL_TTL ); return $out; } $out = array( 'shop' => isset( $res['shop'] ) ? sanitize_text_field( (string) $res['shop'] ) : self::shop_id(), 'relay_url' => isset( $res['relay_url'] ) ? esc_url_raw( (string) $res['relay_url'] ) : '', 'pixel_id' => isset( $res['pixel_id'] ) && is_string( $res['pixel_id'] ) ? sanitize_text_field( $res['pixel_id'] ) : null, 'forwarding' => isset( $res['forwarding'] ) && in_array( $res['forwarding'], array( 'ready', 'pending', 'local' ), true ) ? $res['forwarding'] : 'local', 'needs_pixel' => ! empty( $res['needs_pixel'] ), 'ads_key_linked' => ! empty( $res['ads_key_linked'] ), 'consent_mode' => isset( $res['consent_mode'] ) ? sanitize_text_field( (string) $res['consent_mode'] ) : '', 'fetched_at' => time(), ); $prev = get_option( self::CONFIG_LKG ); set_transient( self::CONFIG_TRANSIENT, $out, self::CONFIG_TTL ); update_option( self::CONFIG_LKG, $out, false ); if ( ! empty( $out['pixel_id'] ) && ! get_option( self::OPTION_SINCE ) ) { update_option( self::OPTION_SINCE, time(), false ); } // The pixel id is rendered into every page. A full-page cache would keep // serving the old one (or no script at all) until each page expired, so // a change of pixel purges the site once. $prev_pid = is_array( $prev ) && ! empty( $prev['pixel_id'] ) ? (string) $prev['pixel_id'] : ''; if ( (string) $out['pixel_id'] !== $prev_pid && class_exists( 'Xpay_Admin_REST' ) ) { Xpay_Admin_REST::purge_site_page_cache(); } return $out; } private static function empty_config() { return array( 'shop' => self::shop_id(), 'relay_url' => '', 'pixel_id' => null, 'forwarding' => 'local', 'needs_pixel' => false, 'ads_key_linked' => false, 'consent_mode' => '', 'fetched_at' => 0, ); } /** The relay base the BROWSER posts to: the config's, else our constant. */ private static function browser_relay( $config ) { $url = ! empty( $config['relay_url'] ) ? (string) $config['relay_url'] : XPAY_WC_PIXEL_RELAY; return untrailingslashit( $url ); } /** * Authenticated server call to the relay. * * @param string $method GET|POST. * @param string $path Path under the relay base. * @param array|null $body JSON body. * @param int $timeout Seconds. * @return array|WP_Error Decoded body on 2xx; WP_Error carrying status + body otherwise. */ private static function relay( $method, $path, $body = null, $timeout = 6 ) { $args = array( 'method' => $method, 'timeout' => $timeout, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'agentic-commerce-for-woocommerce/' . XPAY_WC_VERSION, 'X-Xpay-Merchant' => Xpay_Plugin::merchant_slug(), 'X-Xpay-Api-Key' => Xpay_Plugin::api_key(), ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( untrailingslashit( XPAY_WC_PIXEL_RELAY ) . $path, $args ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $json = '' !== $raw ? json_decode( $raw, true ) : null; if ( $code >= 200 && $code < 300 ) { return is_array( $json ) ? $json : array(); } return new WP_Error( 'xpay_relay_' . $code, is_array( $json ) && isset( $json['message'] ) ? (string) $json['message'] : 'relay error', array( 'status' => $code, 'body' => $json, ) ); } // ─── click capture ──────────────────────────────────────────────────── /** Sanitise a click id; '' when it does not look like one. */ private static function clean_oppref( $raw ) { $v = is_string( $raw ) ? trim( $raw ) : ''; return ( '' !== $v && preg_match( self::OPPREF_PATTERN, $v ) ) ? $v : ''; } /** * Campaign blob with the Shopify pixel's short keys (s/m/c/t/x/e/op/p). * * @param array $src Raw values (from $_GET or a beacon body). * @return array|null */ private static function clean_attr( $src ) { if ( ! is_array( $src ) ) { return null; } $keys = array( 's' => 80, 'm' => 80, 'c' => 120, 't' => 120, 'x' => 120, 'e' => 10, 'op' => 400, 'p' => 255, ); $out = array(); foreach ( $keys as $k => $max ) { if ( isset( $src[ $k ] ) && is_scalar( $src[ $k ] ) && '' !== (string) $src[ $k ] ) { $out[ $k ] = substr( sanitize_text_field( (string) $src[ $k ] ), 0, $max ); } } if ( isset( $out['e'] ) && ! in_array( $out['e'], array( 'gclid', 'fbclid' ), true ) ) { unset( $out['e'] ); } return $out ? $out : null; } /** * Uncached hits: read the click id straight off the landing URL. * * On a full-page-cached store this never runs for a fresh visitor; the * browser script and the px-click beacon cover that case. */ public function capture_click() { if ( is_admin() || ! self::connected() ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a landing URL, not a form. if ( empty( $_GET['oppref'] ) ) { return; } $oppref = self::clean_oppref( sanitize_text_field( wp_unslash( $_GET['oppref'] ) ) ); if ( '' === $oppref ) { return; } $attr = array( 'op' => $oppref ); $map = array( 'utm_source' => 's', 'utm_medium' => 'm', 'utm_campaign' => 'c', 'utm_term' => 't', 'utm_content' => 'x', ); foreach ( $map as $param => $k ) { if ( isset( $_GET[ $param ] ) ) { $attr[ $k ] = sanitize_text_field( wp_unslash( $_GET[ $param ] ) ); } } if ( isset( $_GET['gclid'] ) ) { $attr['e'] = 'gclid'; } elseif ( isset( $_GET['fbclid'] ) ) { $attr['e'] = 'fbclid'; } // phpcs:enable WordPress.Security.NonceVerification.Recommended $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH ) : '/'; $attr['p'] = '' !== $path ? $path : '/'; $this->store_click( $oppref, self::clean_attr( $attr ), self::request_consent() ); } /** * First-touch store into the WC session (always) and cookies (with consent). * * @param string $oppref Click id. * @param array|null $attr Campaign blob. * @param bool $consent Marketing consent for persistent cookies. * @return void */ private function store_click( $oppref, $attr, $consent ) { $stored = $this->read_oppref(); if ( '' !== $stored && $stored !== $oppref ) { return; // first touch wins, and its campaign stays with it. } if ( function_exists( 'WC' ) ) { if ( ! WC()->session && method_exists( WC(), 'initialize_session' ) ) { WC()->initialize_session(); } if ( WC()->session ) { WC()->session->set( self::SESSION_OPPREF, $oppref ); if ( $attr ) { WC()->session->set( self::SESSION_ATTR, $attr ); } if ( method_exists( WC()->session, 'set_customer_session_cookie' ) ) { WC()->session->set_customer_session_cookie( true ); } } } if ( $consent ) { $this->set_cookie( self::COOKIE_OPPREF, $oppref ); if ( $attr ) { $this->set_cookie( self::COOKIE_ATTR, (string) wp_json_encode( $attr ) ); } } } private function set_cookie( $name, $value ) { if ( headers_sent() || '' === $value ) { return; } setcookie( $name, $value, array( 'expires' => time() + self::COOKIE_TTL, // '/' like the browser script, or a subdirectory install keeps two. 'path' => '/', 'domain' => COOKIE_DOMAIN ? COOKIE_DOMAIN : '', 'secure' => is_ssl(), // Readable by our own script, which writes the same cookie on cached pages. 'httponly' => false, 'samesite' => 'Lax', ) ); $_COOKIE[ $name ] = $value; } /** Stored click id: cookie (JS or PHP wrote it), then WC session. */ private function read_oppref() { if ( ! empty( $_COOKIE[ self::COOKIE_OPPREF ] ) ) { $v = self::clean_oppref( sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_OPPREF ] ) ) ); if ( '' !== $v ) { return $v; } } if ( function_exists( 'WC' ) && WC()->session ) { return self::clean_oppref( (string) WC()->session->get( self::SESSION_OPPREF ) ); } return ''; } private function read_attr() { if ( ! empty( $_COOKIE[ self::COOKIE_ATTR ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON; decoded then every field sanitised in clean_attr(). $raw = wp_unslash( $_COOKIE[ self::COOKIE_ATTR ] ); if ( is_string( $raw ) && strlen( $raw ) <= 2048 ) { $a = self::clean_attr( json_decode( $raw, true ) ); if ( $a ) { return $a; } } } if ( function_exists( 'WC' ) && WC()->session ) { return self::clean_attr( WC()->session->get( self::SESSION_ATTR ) ); } return null; } /** `POST /wp-json/xpay/v1/px-click` — the cache-immune path into the WC session. */ public function register_rest() { if ( ! self::connected() ) { return; } register_rest_route( 'xpay/v1', self::REST_ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'rest_click' ), // A shopper from an ad is never logged in. Same hardening as // /classify: same-origin, a per-IP throttle, a size cap, and a // strict shape — and it can only write this visitor's own session. 'permission_callback' => '__return_true', ) ); } /** * Store a click id the browser saw on a cached landing page. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_click( $request ) { if ( ! self::same_origin( $request ) ) { return new WP_REST_Response( null, 403 ); } if ( ! self::rate_ok() ) { return new WP_REST_Response( null, 429 ); } $raw = (string) $request->get_body(); if ( strlen( $raw ) > 2048 ) { return new WP_REST_Response( null, 413 ); } $body = json_decode( $raw, true ); if ( ! is_array( $body ) || empty( $body['oppref'] ) ) { return new WP_REST_Response( null, 204 ); } $oppref = self::clean_oppref( sanitize_text_field( (string) $body['oppref'] ) ); if ( '' === $oppref ) { return new WP_REST_Response( null, 204 ); } $attr = isset( $body['attr'] ) ? self::clean_attr( $body['attr'] ) : null; if ( $attr && isset( $attr['op'] ) && $attr['op'] !== $oppref ) { $attr = null; // never pair a click id with another click's campaign. } // Cookies were already written by the browser when it had consent; the // session is the only thing this endpoint adds. $this->store_click( $oppref, $attr, false ); return new WP_REST_Response( null, 204 ); } private static function same_origin( $request ) { $home = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( '' === $home ) { return false; } $origin = (string) $request->get_header( 'origin' ); if ( '' !== $origin ) { return strtolower( (string) wp_parse_url( $origin, PHP_URL_HOST ) ) === $home; } $referer = (string) $request->get_header( 'referer' ); return '' !== $referer && strtolower( (string) wp_parse_url( $referer, PHP_URL_HOST ) ) === $home; } private static function rate_ok() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( '' === $ip ) { return true; } $key = 'xpay_pxc_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= 20 ) { return false; } set_transient( $key, $count + 1, 5 * MINUTE_IN_SECONDS ); return true; } /** * Copy the click onto the order, with this shopper's consent at the time. * * @param WC_Order $order Order being created. * @return void */ public function tag_order( $order ) { if ( ! $order instanceof WC_Order || ! self::connected() ) { return; } $oppref = $this->read_oppref(); if ( '' !== $oppref && '' === (string) $order->get_meta( self::META_OPPREF, true ) ) { $order->update_meta_data( self::META_OPPREF, $oppref ); $attr = $this->read_attr(); if ( $attr && ( empty( $attr['op'] ) || $attr['op'] === $oppref ) ) { $order->update_meta_data( self::META_ATTR, $attr ); } } // The backup runs in cron, where there is no shopper to ask. Record the // answer now, while there is. $order->update_meta_data( self::META_CONSENT, self::request_consent() ? 'yes' : 'no' ); if ( 'woocommerce_store_api_checkout_update_order_from_request' === current_action() ) { $order->save(); } } // ─── storefront ─────────────────────────────────────────────────────── /** Should the storefront script load on this request? */ private static function storefront_ok() { if ( is_admin() || ! self::is_active() ) { return false; } // Our own pixel-detection loopback must not find our own script. if ( isset( $_SERVER['HTTP_X_XPAY_PROBE'] ) ) { return false; } return true; } /** Enqueue the pixel and everything it needs for this page. */ public function enqueue() { if ( ! self::storefront_ok() ) { return; } $file = XPAY_WC_PATH . self::SCRIPT_REL; if ( ! file_exists( $file ) ) { return; } $config = self::config(); $mtime = @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- existence checked above. wp_enqueue_script( self::SCRIPT_HANDLE, XPAY_WC_URL . self::SCRIPT_REL, array(), $mtime ? (string) $mtime : XPAY_WC_VERSION, true ); $data = array( 'relay' => self::browser_relay( $config ) . '/pixel/events', 'click' => esc_url_raw( rest_url( 'xpay/v1' . self::REST_ROUTE ) ), 'shop' => self::shop_id(), 'pid' => ! empty( $config['pixel_id'] ) ? (string) $config['pixel_id'] : '', 'fwd' => self::should_forward(), 'consent' => self::default_consent(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', 'page' => 'other', // Pending adds are NOT rendered here: this array is page HTML, and a // full-page cache would replay one shopper's adds to every visitor. // The browser fetches them from the (never cached) fragments endpoint // when the add cookie says one is waiting. 'frag' => class_exists( 'WC_AJAX' ) ? esc_url_raw( WC_AJAX::get_endpoint( 'get_refreshed_fragments' ) ) : '', ); if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) { $order = $this->received_order(); if ( $order ) { $data['page'] = 'order'; $data['order'] = $this->order_payload( $order, self::request_consent() ); $order->update_meta_data( self::META_RENDERED, time() ); $order->save(); } } elseif ( function_exists( 'is_checkout' ) && is_checkout() ) { $data['page'] = 'checkout'; $data['checkout'] = $this->cart_payload(); } elseif ( function_exists( 'is_product' ) && is_product() ) { $product = wc_get_product( get_queried_object_id() ); if ( $product ) { $data['page'] = 'product'; $data['product'] = self::item( $product, 1, (float) wc_get_price_to_display( $product ) ); } } wp_localize_script( self::SCRIPT_HANDLE, 'XpayOaiPx', $data ); } /** * The order on the order-received page — only with a matching order key, and * only the first time it renders (a reload must not resend the purchase). * * @return WC_Order|null */ private function received_order() { $order_id = absint( get_query_var( 'order-received' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- WooCommerce's own order-key check, below. $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; if ( ! $order_id || '' === $key ) { return null; } $order = wc_get_order( $order_id ); if ( ! $order instanceof WC_Order || ! hash_equals( (string) $order->get_order_key(), (string) $key ) ) { return null; } if ( '' !== (string) $order->get_meta( self::META_RENDERED, true ) ) { return null; } // Paid orders only. A BACS / cheque / pending-redirect order is not a // purchase yet; the server backup reports it when it is paid. if ( ! $order->is_paid() ) { return null; } return $order; } /** * One line item in the Shopify pixel's normalised shape. * * @param WC_Product $product Product (or variation). * @param int $qty Quantity. * @param float $price Unit price, major units. * @return array */ private static function item( $product, $qty, $price ) { $parent_id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(); $cats = wc_get_product_category_list( $parent_id, ', ' ); $name = wp_strip_all_tags( $product->get_name() ); return array( 'id' => (string) $product->get_id(), 'qty' => (int) $qty, 'price' => round( (float) $price, 2 ), 'sku' => (string) $product->get_sku(), // Both keys: the relay's server path reads `name`, the browser path `title`. 'name' => $name, 'title' => $name, 'product_type' => $cats ? substr( wp_strip_all_tags( $cats ), 0, 120 ) : null, 'vendor' => null, ); } private function cart_payload() { if ( ! function_exists( 'WC' ) || ! WC()->cart ) { return null; } $items = array(); foreach ( WC()->cart->get_cart() as $line ) { if ( empty( $line['data'] ) || ! $line['data'] instanceof WC_Product ) { continue; } $qty = max( 1, (int) $line['quantity'] ); $items[] = self::item( $line['data'], $qty, (float) $line['line_total'] / $qty ); } $email = ''; if ( self::request_consent() && is_user_logged_in() && WC()->customer ) { $email = (string) WC()->customer->get_billing_email(); } return array( 'amount' => round( (float) WC()->cart->get_total( 'edit' ), 2 ), 'items' => $items, 'email' => '' !== $email ? $email : null, ); } /** * Purchase payload, shared by the thank-you page and the server backup so the * two can never disagree about an order. * * @param WC_Order $order Order. * @param bool $consent Marketing consent. * @return array */ private function order_payload( $order, $consent ) { $items = array(); foreach ( $order->get_items() as $line ) { if ( ! $line instanceof WC_Order_Item_Product ) { continue; } $product = $line->get_product(); if ( ! $product ) { continue; } $qty = max( 1, (int) $line->get_quantity() ); $items[] = self::item( $product, $qty, (float) $line->get_total() / $qty ); } $email = $consent ? (string) $order->get_billing_email() : ''; return array( 'event_id' => 'pp-ord-' . $order->get_id(), 'order_id' => (string) $order->get_id(), 'amount' => round( (float) $order->get_total(), 2 ), 'currency' => $order->get_currency(), 'items' => $items, 'email' => '' !== $email ? $email : null, 'oppref' => (string) $order->get_meta( self::META_OPPREF, true ), ); } // ─── adds ───────────────────────────────────────────────────────────── /** * Record an add-to-cart server-side. The browser reports it — from the AJAX * response's fragments, or on the next page for a classic form post — so the * product, price and quantity come from WooCommerce, not from the button. * * Store API (block) adds are recorded too and surface on the next page. */ public function flag_add( $cart_item_key, $product_id, $quantity, $variation_id ) { if ( ! self::is_active() || ! function_exists( 'WC' ) || ! WC()->session ) { return; } $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product ) { return; } $pending = WC()->session->get( self::SESSION_ADDS ); $pending = is_array( $pending ) ? $pending : array(); if ( count( $pending ) >= 10 ) { array_shift( $pending ); // bounded; a burst of adds keeps the newest. } $pending[] = array( 'k' => substr( md5( $cart_item_key . microtime() ), 0, 12 ), 'item' => self::item( $product, max( 1, (int) $quantity ), (float) wc_get_price_to_display( $product ) ), ); WC()->session->set( self::SESSION_ADDS, $pending ); // Tell the browser an add is waiting (a block add's Store API response, // or a form post that redirects). The value carries nothing. if ( ! headers_sent() ) { setcookie( self::COOKIE_ADD, '1', array( 'expires' => 0, 'path' => '/', 'secure' => is_ssl(), 'httponly' => false, 'samesite' => 'Lax', ) ); } } /** Pending adds, cleared once handed to a page. */ private function take_pending_adds() { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return array(); } $pending = WC()->session->get( self::SESSION_ADDS ); if ( ! is_array( $pending ) || ! $pending ) { return array(); } WC()->session->set( self::SESSION_ADDS, array() ); return array_values( $pending ); } /** Holder element the AJAX fragment replaces. Data only — no script. */ public function render_adds_holder() { if ( ! self::storefront_ok() ) { return; } echo '
'; } /** * Ride the add-to-cart AJAX response. * * @param array $fragments Fragments. * @return array */ public function adds_fragment( $fragments ) { if ( ! is_array( $fragments ) || ! self::storefront_ok() ) { return $fragments; } $adds = $this->take_pending_adds(); $fragments['div.xpay-px-adds'] = ''; return $fragments; } // ─── server-side purchase backup ────────────────────────────────────── /** * Safety net for gateways that never fire payment_complete. */ public function on_status_changed( $order_id, $from_status, $to_status ) { $paid = function_exists( 'wc_get_is_paid_statuses' ) ? wc_get_is_paid_statuses() : array( 'processing', 'completed' ); if ( in_array( $to_status, $paid, true ) ) { $this->enqueue_backup( $order_id ); } } /** * Stamp a fresh in-flight sentinel and schedule the send, exactly as * Xpay_Order_Events does: the stamp stops three hooks enqueueing three jobs, * and a stale stamp (cron never woke) never bricks the order. * * @param int $order_id Order id. * @return void */ public function enqueue_backup( $order_id ) { if ( ! self::is_active() ) { return; } $order = wc_get_order( $order_id ); if ( ! $order instanceof WC_Order || $this->backup_done_or_in_flight( $order ) ) { return; } $order->update_meta_data( self::META_BACKUP, 'queued:' . time() ); $order->save(); if ( ! wp_next_scheduled( self::BACKUP_HOOK, array( (int) $order_id ) ) ) { wp_schedule_single_event( time(), self::BACKUP_HOOK, array( (int) $order_id ) ); } if ( function_exists( 'fastcgi_finish_request' ) || function_exists( 'litespeed_finish_request' ) ) { if ( empty( $this->shutdown_queue ) ) { add_action( 'shutdown', array( $this, 'dispatch_shutdown_queue' ), 101 ); } $this->shutdown_queue[] = (int) $order_id; } } private function backup_done_or_in_flight( $order ) { $sent = (string) $order->get_meta( self::META_BACKUP, true ); if ( '' === $sent ) { return false; } if ( 0 !== strpos( $sent, 'queued:' ) ) { return true; // an epoch, or a terminal 'rejected:'.
}
return ( time() - (int) substr( $sent, 7 ) ) < self::SENTINEL_STALE;
}
/** Inline send after the response has gone to the shopper. */
public function dispatch_shutdown_queue() {
if ( function_exists( 'fastcgi_finish_request' ) ) {
fastcgi_finish_request();
} elseif ( function_exists( 'litespeed_finish_request' ) ) {
litespeed_finish_request();
}
foreach ( array_unique( $this->shutdown_queue ) as $order_id ) {
$this->send_backup( $order_id );
}
$this->shutdown_queue = array();
}
/**
* Send the purchase to the relay. Same event id as the thank-you page
* (`pp-ord-`), so the relay — and OpenAI — keep exactly one.
*
* @param int $order_id Order id.
* @return void
*/
public function send_backup( $order_id ) {
if ( ! self::is_active() ) {
return;
}
$order = wc_get_order( $order_id );
if ( ! $order instanceof WC_Order ) {
return;
}
$sent = (string) $order->get_meta( self::META_BACKUP, true );
if ( '' !== $sent && 0 !== strpos( $sent, 'queued:' ) ) {
return;
}
// Record, but do not forward, when another OpenAI pixel owns this
// store's conversions (a second conversion would double-count), or when
// no shopper placed the order (wp-admin, subscription renewals) — those
// are not ad conversions. The relay still learns the order exists.
$body = array(
'pv' => 'wc1-s',
'events' => array( $this->backup_event( $order ) ),
);
if ( ! self::should_forward() || in_array( (string) $order->get_created_via(), array( 'admin', 'subscription' ), true ) ) {
$body['fwd'] = false;
}
$res = self::relay( 'POST', '/api/woo/server-events', $body, 8 );
if ( is_wp_error( $res ) ) {
$data = $res->get_error_data();
$status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
if ( $status >= 400 && $status < 500 && 429 !== $status ) {
// The relay understood and refused; retrying the same body cannot help.
$order->update_meta_data( self::META_BACKUP, 'rejected:' . $status );
} else {
$order->delete_meta_data( self::META_BACKUP ); // transport/5xx → retry later.
}
$order->save();
return;
}
$order->update_meta_data( self::META_BACKUP, time() );
$order->save();
}
/**
* The order_created event for the relay.
*
* @param WC_Order $order Order.
* @return array
*/
private function backup_event( $order ) {
$consent_meta = (string) $order->get_meta( self::META_CONSENT, true );
$consent = '' !== $consent_meta ? 'yes' === $consent_meta : self::default_consent();
$p = $this->order_payload( $order, $consent );
$oppref = $p['oppref'];
$entry = (string) $order->get_meta( '_wc_order_attribution_session_entry', true );
if ( '' === $oppref && '' !== $entry ) {
// WooCommerce's own order attribution keeps the landing URL — the
// same recovery the Shopify backup does from landing_site.
$q = array();
wp_parse_str( (string) wp_parse_url( $entry, PHP_URL_QUERY ), $q );
$oppref = isset( $q['oppref'] ) ? self::clean_oppref( (string) $q['oppref'] ) : '';
}
$attr = self::clean_attr( $order->get_meta( self::META_ATTR, true ) );
if ( ! $attr ) {
// No ad click: fall back to WooCommerce's own order attribution so an
// organic ChatGPT order still says where it came from.
$native = array();
foreach ( array(
's' => 'utm_source',
'm' => 'utm_medium',
'c' => 'utm_campaign',
't' => 'utm_term',
'x' => 'utm_content',
) as $k => $f ) {
$v = (string) $order->get_meta( '_wc_order_attribution_' . $f, true );
if ( '' !== $v && '(none)' !== $v ) {
$native[ $k ] = $v;
}
}
if ( '' !== $entry ) {
$native['p'] = (string) wp_parse_url( $entry, PHP_URL_PATH );
}
$attr = self::clean_attr( $native );
}
$referrer = (string) wp_parse_url( (string) $order->get_meta( '_wc_order_attribution_referrer', true ), PHP_URL_HOST );
$referrer = strtolower( preg_replace( '/^www\./i', '', $referrer ) );
$own = strtolower( preg_replace( '/^www\./i', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) );
$created = $order->get_date_created();
$user = array( 'email' => $p['email'] );
if ( $consent ) {
$user['ip_address'] = (string) $order->get_customer_ip_address();
$user['user_agent'] = substr( (string) $order->get_customer_user_agent(), 0, 512 );
}
return array(
'event_id' => $p['event_id'],
'name' => 'order_created',
'ts_ms' => $created ? $created->getTimestamp() * 1000 : time() * 1000,
'source_url' => '' !== $entry ? esc_url_raw( $entry ) : home_url( '/' ),
'oppref' => '' !== $oppref ? $oppref : null,
'referrer_host' => '' !== $referrer && $referrer !== $own ? substr( $referrer, 0, 120 ) : null,
'client_id' => null,
'created_via' => substr( (string) $order->get_created_via(), 0, 40 ),
'utm_source' => isset( $attr['s'] ) ? $attr['s'] : null,
'utm_medium' => isset( $attr['m'] ) ? $attr['m'] : null,
'utm_campaign' => isset( $attr['c'] ) ? $attr['c'] : null,
'utm_term' => isset( $attr['t'] ) ? $attr['t'] : null,
'utm_content' => isset( $attr['x'] ) ? $attr['x'] : null,
'landing_path' => isset( $attr['p'] ) ? $attr['p'] : null,
'ext_click' => isset( $attr['e'] ) ? $attr['e'] : null,
'page_age_ms' => null,
'adds_on_page' => null,
'consent' => $consent,
'browser_fired' => false,
'data' => array(
'amount' => $p['amount'],
'currency' => $p['currency'],
'order_id' => $p['order_id'],
'items' => $p['items'],
),
'user' => $user,
);
}
/**
* Daily sweep: paid orders from the last 7 days (OpenAI's attribution window)
* whose backup never landed. Bounded, so a large store never stalls cron.
*/
public function sweep_backups() {
if ( ! self::is_active() || ! function_exists( 'wc_get_orders' ) ) {
return;
}
// Only orders placed after tracking was switched on. Without this, the
// first sweep reported a week of orders that predate the pixel.
$since = (int) get_option( self::OPTION_SINCE );
if ( ! $since ) {
$since = time();
update_option( self::OPTION_SINCE, $since, false );
}
$ids = wc_get_orders(
array(
'limit' => 50,
'return' => 'ids',
'status' => function_exists( 'wc_get_is_paid_statuses' ) ? wc_get_is_paid_statuses() : array( 'processing', 'completed' ),
'date_created' => '>' . max( time() - 7 * DAY_IN_SECONDS, $since ),
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded daily cron, 50 rows.
array(
'key' => self::META_BACKUP,
'compare' => 'NOT EXISTS',
),
),
)
);
foreach ( (array) $ids as $id ) {
$this->enqueue_backup( $id );
}
}
// ─── admin actions ────────────────────────────────────────────────────
/**
* Capability gate shared by every admin action. Each handler checks its own
* nonce right after, before it reads a single field.
*
* @return void
*/
private static function guard() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'agentic-commerce-for-woocommerce' ), '', array( 'response' => 403 ) );
}
}
/**
* Remember a one-time result for the next Ads screen view. Kept out of the
* URL so a remote message can never be injected through a link.
*/
private static function flash( $tone, $message ) {
set_transient(
'xpay_px_flash_' . get_current_user_id(),
array(
'tone' => $tone,
'message' => (string) $message,
),
5 * MINUTE_IN_SECONDS
);
}
/** Per-user transient holding the pixel chooser after a `choose` answer. */
private static function choose_key() {
return 'xpay_px_choose_' . get_current_user_id();
}
/**
* Pixels to choose from, or null. Ids and names only — no credential.
*
* @return array{pixels:array,can_create:bool}|null
*/
public static function pending_choice() {
$c = get_transient( self::choose_key() );
return is_array( $c ) && ! empty( $c['pixels'] ) ? $c : null;
}
/** Read and clear the one-time result. */
public static function take_flash() {
$key = 'xpay_px_flash_' . get_current_user_id();
$f = get_transient( $key );
delete_transient( $key );
return is_array( $f ) ? $f : null;
}
private static function back() {
wp_safe_redirect( Xpay_Admin::url( '-ads' ) );
exit;
}
/**
* Connect conversion tracking with the dashboard-linked key, or a key pasted
* once. ⛔ A pasted key is forwarded and dropped: never saved, logged or echoed.
*/
public function handle_connect() {
self::guard();
check_admin_referer( 'xpay_px_connect' );
$body = array();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- an opaque credential; trimmed and length-capped, never stored or echoed.
$key = isset( $_POST['ads_api_key'] ) ? trim( (string) wp_unslash( $_POST['ads_api_key'] ) ) : '';
if ( '' !== $key ) {
$body['ads_api_key'] = substr( $key, 0, 512 );
}
// A pick from the pixel chooser ("new" asks the relay to create one).
$pick = isset( $_POST['pixel_id'] ) ? sanitize_text_field( wp_unslash( $_POST['pixel_id'] ) ) : '';
if ( '' !== $pick ) {
$body['pixel_id'] = substr( $pick, 0, 128 );
}
$res = self::relay( 'POST', '/api/woo/connect', (object) $body, 25 );
unset( $key, $body );
// ⛔ needs_pixel is a 409, no_key / key_rejected a 422 and not_permitted a
// 403, so relay() hands them back as WP_Error. Read the status from the
// error body; otherwise every one of them would read "could not reach our
// tracking service" — and a refusal worded as an outage is the trap the
// Shopify app already paid for.
$data = $res;
if ( is_wp_error( $res ) ) {
$err = $res->get_error_data();
$data = is_array( $err ) && isset( $err['body'] ) && is_array( $err['body'] ) ? $err['body'] : array();
}
$status = isset( $data['status'] ) ? (string) $data['status'] : '';
$msg = isset( $data['message'] ) ? sanitize_text_field( (string) $data['message'] ) : '';
delete_transient( self::choose_key() );
switch ( $status ) {
case 'choose':
case 'pixel_not_found':
$pixels = array();
foreach ( ( isset( $data['pixels'] ) && is_array( $data['pixels'] ) ) ? $data['pixels'] : array() as $px ) {
if ( ! is_array( $px ) || empty( $px['pixelId'] ) ) {
continue;
}
$pixels[] = array(
'pixel_id' => sanitize_text_field( (string) $px['pixelId'] ),
'name' => sanitize_text_field( isset( $px['name'] ) ? (string) $px['name'] : '' ),
);
}
set_transient(
self::choose_key(),
array(
'pixels' => array_slice( $pixels, 0, 50 ),
'can_create' => ! empty( $data['canCreatePixel'] ),
),
15 * MINUTE_IN_SECONDS
);
self::flash(
'info',
'pixel_not_found' === $status
? __( 'We could not find that pixel in your ads account. Pick one of the pixels below.', 'agentic-commerce-for-woocommerce' )
: __( 'Your ads account already has pixels. Pick the one to report purchases to.', 'agentic-commerce-for-woocommerce' )
);
break;
case 'connected':
self::flash( 'info', __( 'Conversion tracking is connected. Purchases from ChatGPT Ads will now be reported to your ads account.', 'agentic-commerce-for-woocommerce' ) );
break;
case 'needs_pixel':
self::flash( 'warn', __( 'Your ads account only lets an account admin create a pixel. Create one in ChatGPT Ads Manager (Events → Pixels), then click "Set up conversion tracking" again and we will pick it up.', 'agentic-commerce-for-woocommerce' ) );
break;
case 'not_permitted':
self::flash( 'warn', __( 'Your key works, but your ads account does not allow this action for it. Ask an account admin to create the pixel, or use the manual option below.', 'agentic-commerce-for-woocommerce' ) );
break;
case 'key_rejected':
self::flash( 'bad', __( 'The ads platform did not accept that key. Check you copied the whole key from ChatGPT Ads Manager → Settings → API keys.', 'agentic-commerce-for-woocommerce' ) );
break;
case 'no_key':
self::flash( 'warn', __( 'No ads account is linked yet. Link it on your xpay dashboard (ChatGPT Ads → Settings), then click "Set up conversion tracking" again.', 'agentic-commerce-for-woocommerce' ) );
break;
default:
self::flash( 'bad', '' !== $msg ? $msg : __( 'We could not reach our tracking service. Nothing was changed — try again in a minute.', 'agentic-commerce-for-woocommerce' ) );
}
self::refresh_config();
self::back();
}
/** Manual pixel id + Conversions API key. Same rule: forwarded, never stored. */
public function handle_manual() {
self::guard();
check_admin_referer( 'xpay_px_manual' );
$pixel_id = isset( $_POST['pixel_id'] ) ? sanitize_text_field( wp_unslash( $_POST['pixel_id'] ) ) : '';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- opaque credential; trimmed, capped, never stored or echoed.
$capi = isset( $_POST['capi_key'] ) ? trim( (string) wp_unslash( $_POST['capi_key'] ) ) : '';
if ( '' === $pixel_id || '' === $capi ) {
self::flash( 'warn', __( 'Enter both the pixel ID and the Conversions API key.', 'agentic-commerce-for-woocommerce' ) );
self::back();
}
$res = self::relay(
'POST',
'/api/woo/manual',
array(
'pixel_id' => substr( $pixel_id, 0, 128 ),
'capi_key' => substr( $capi, 0, 512 ),
),
20
);
unset( $capi );
if ( is_wp_error( $res ) ) {
$data = $res->get_error_data();
$code = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
self::flash(
'bad',
$code >= 400 && $code < 500
? __( 'The ads platform did not accept that pixel ID and key together. Check both values in ChatGPT Ads Manager.', 'agentic-commerce-for-woocommerce' )
: __( 'We could not reach our tracking service. Nothing was changed — try again in a minute.', 'agentic-commerce-for-woocommerce' )
);
} else {
self::flash( 'info', __( 'Pixel saved. Purchases from ChatGPT Ads will now be reported to your ads account.', 'agentic-commerce-for-woocommerce' ) );
}
self::refresh_config();
self::back();
}
/** Re-read the relay config now. */
public function handle_refresh() {
self::guard();
check_admin_referer( 'xpay_px_refresh' );
self::refresh_config();
self::back();
}
/** On/off and forwarding switches. */
public function handle_settings() {
self::guard();
check_admin_referer( 'xpay_px_settings' );
$enabled = isset( $_POST['enabled'] ) ? sanitize_key( wp_unslash( $_POST['enabled'] ) ) : 'auto';
$forward = isset( $_POST['forward'] ) ? sanitize_key( wp_unslash( $_POST['forward'] ) ) : 'auto';
update_option( self::OPTION_ENABLED, in_array( $enabled, array( 'auto', '1', '0' ), true ) ? $enabled : 'auto' );
update_option( self::OPTION_FORWARD, in_array( $forward, array( 'auto', 'on', 'off' ), true ) ? $forward : 'auto' );
self::flash( 'info', __( 'Tracking settings saved.', 'agentic-commerce-for-woocommerce' ) );
self::back();
}
/**
* Plain-English status for the admin screens.
*
* @return array{state:string,headline:string,detail:string}
*/
public static function status() {
if ( ! self::connected() ) {
return array(
'state' => 'low',
'headline' => __( 'xpay ChatGPT Ads pixel: not available until your store is connected', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'Connect your store first. Nothing is tracked before that.', 'agentic-commerce-for-woocommerce' ),
);
}
$c = self::config();
if ( ! self::is_active() ) {
return array(
'state' => 'low',
'headline' => __( 'xpay ChatGPT Ads pixel: off', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'It switches on when you link a ChatGPT Ads pixel on the ChatGPT Ads screen. Until then no script is added to your storefront and no order is sent for ads measurement.', 'agentic-commerce-for-woocommerce' ),
);
}
if ( ! self::should_forward() ) {
return array(
'state' => 'med',
'headline' => __( 'xpay ChatGPT Ads pixel: recording only', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'Another OpenAI pixel is on your storefront, so we record events for your reports but do not send them to OpenAI. That keeps each purchase counted once.', 'agentic-commerce-for-woocommerce' ),
);
}
if ( 'ready' === $c['forwarding'] ) {
return array(
'state' => 'pass',
'headline' => __( 'xpay ChatGPT Ads pixel: on and reporting to your ads account', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'Funnel events come from shoppers\' browsers and every paid order is backed up from your server, under one event id so it is counted once.', 'agentic-commerce-for-woocommerce' ),
);
}
if ( 'pending' === $c['forwarding'] ) {
return array(
'state' => 'med',
'headline' => __( 'xpay ChatGPT Ads pixel: pixel linked, waiting for its Conversions API key', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'Events are recorded but not yet sent to OpenAI. Finish setup on the ChatGPT Ads screen.', 'agentic-commerce-for-woocommerce' ),
);
}
return array(
'state' => 'med',
'headline' => __( 'xpay ChatGPT Ads pixel: recording for your own reports', 'agentic-commerce-for-woocommerce' ),
'detail' => __( 'No ads pixel is linked, so nothing is sent to OpenAI. Link one on the ChatGPT Ads screen when you start running ads.', 'agentic-commerce-for-woocommerce' ),
);
}
}