license = $license; // Attribute at creation, on every checkout path (classic, Store API/blocks, // and programmatic/REST). Idempotent: the first hook to fire tags the order. add_action( 'woocommerce_checkout_order_processed', array( $this, 'on_order' ), 20 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'on_order' ), 20 ); add_action( 'woocommerce_new_order', array( $this, 'on_order' ), 20 ); // Invalidate the cached dashboard stats when orders change. add_action( 'woocommerce_new_order', array( $this, 'flush_stats' ) ); add_action( 'woocommerce_order_status_changed', array( $this, 'flush_stats' ) ); /** * Fires when the order-tracking module boots. Add-ons may hook here to * contribute extra agent signals via the `arwc_agent_detection` filter. * * @param ARWC_Order_Tracking $tracking The tracking instance. */ do_action( 'arwc_order_tracking_init', $this ); } /** * Hook callback: normalize an order id/object and attribute it. * * @param int|WC_Order $order_or_id Order id or object (varies by hook). * @return void */ public function on_order( $order_or_id ) { $order = ( $order_or_id instanceof WC_Order ) ? $order_or_id : wc_get_order( $order_or_id ); if ( $order instanceof WC_Order ) { $this->attribute( $order ); } } /** * Attribute an order (best-effort), writing the source/confidence/signals meta. * Idempotent — skips an order that is already tagged. * * @param WC_Order $order Order. * @return void */ public function attribute( WC_Order $order ) { if ( '' !== (string) $order->get_meta( self::META_AGENT_SOURCE ) ) { return; } $result = $this->detect( $order ); $order->update_meta_data( self::META_AGENT_SOURCE, $result['source'] ); $order->update_meta_data( self::META_CONFIDENCE, $result['confidence'] ); $order->update_meta_data( self::META_SIGNALS, implode( ',', $result['signals'] ) ); $order->update_meta_data( self::META_TIER, $result['tier'] ); $order->update_meta_data( self::META_MANDATE, $result['mandate'] ); $order->save(); $this->flush_stats(); $this->maybe_sync( $order, $result ); } /** * Report a detected agent order to the merchant's AgenticTrack dashboard * (Pro). Fire-and-forget by design: the request is non-blocking with a short * timeout and every failure path is silently ignored — a tracking hiccup * must NEVER break or slow the merchant's checkout. The site endpoint is * idempotent on (store, order ref), so a stray double-send is harmless. * * Only agent-attributed orders are sent, only when the site is Pro, and only * once per order. Human orders never leave the store. The payload carries * order number / total / currency / store URL / detection evidence codes — * no customer PII. * * @param WC_Order $order Order. * @param array{source:string,tier:string,confidence:string,signals:string[],mandate:string} $result Detection result. * @return void */ public function maybe_sync( WC_Order $order, array $result ) { // Human / unattributed orders are never reported. if ( '' === (string) $result['tier'] ) { return; } // Dashboard sync is a Pro feature (detection itself stays free/local). if ( ! $this->license->is_pro() ) { return; } // Once per order, ever (set before posting — see below). if ( '' !== (string) $order->get_meta( self::META_SYNCED ) ) { return; } /** * Filter whether detected agent orders are reported to the AgenticTrack * dashboard. Return false to keep detection purely local. * * @param bool $enabled Default true (when Pro). * @param WC_Order $order The order. */ if ( ! apply_filters( 'arwc_agent_order_sync_enabled', true, $order ) ) { return; } $key = trim( (string) get_option( 'arwc_license_key', '' ) ); $url = $this->ingest_url(); if ( '' === $key || '' === $url ) { return; } $created = $order->get_date_created(); $payload = array( 'license' => $key, 'site' => get_site_url(), 'order_ref' => (string) $order->get_order_number(), 'tier' => (string) $result['tier'], 'agent' => (string) $result['source'], 'amount' => (float) $order->get_total(), 'currency' => (string) $order->get_currency(), 'detected_at' => $created ? $created->date( 'c' ) : gmdate( 'c' ), 'signals' => array( 'codes' => array_values( (array) $result['signals'] ), 'confidence' => (string) $result['confidence'], 'mandate' => (string) $result['mandate'], ), ); // Mark synced BEFORE posting so a hook that re-fires during the request // can't double-send; the server-side dedupe is the second net. $order->update_meta_data( self::META_SYNCED, (string) time() ); $order->save(); // Non-blocking: WordPress returns immediately without reading a response; // any transport error is discarded. Checkout is never affected. wp_remote_post( $url, array( 'timeout' => 2, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $payload ), ) ); } /** * The AgenticTrack ingest endpoint (filterable for staging/self-hosted). * * @return string */ protected function ingest_url() { /** * Filter the AgenticTrack agent-order ingest endpoint. * * @param string $url Default production endpoint. */ return esc_url_raw( (string) apply_filters( 'arwc_agent_ingest_url', self::DEFAULT_INGEST_URL ) ); } /** * Attribute one order into a tier. * * Two tiers, deliberately kept separate: * - TIER 1 "verified": the order came through an agent checkout that presented * a signed payment mandate (UCP/ACP + AP2 — Intent/Cart/Payment credentials). * This is provable, not a guess, so it carries NO hedging. It is only ever * set from a concrete artifact (see {@see verify()}); heuristics can never * promote an order to verified. * - TIER 2 "inferred": best-effort heuristics (agent user-agents, headless/API * checkout, non-browser signature, referrers) with high|medium|low * confidence. Always presented as an estimate. * * @param WC_Order $order Order. * @return array{source:string,tier:string,confidence:string,signals:string[],mandate:string} */ public function detect( WC_Order $order ) { $ua = trim( (string) $order->get_customer_user_agent() ); if ( '' === $ua && isset( $_SERVER['HTTP_USER_AGENT'] ) ) { // Passive read of the request UA in an order hook (not form processing). $ua = trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ); } $created_via = strtolower( (string) $order->get_created_via() ); $device = (string) $order->get_meta( '_wc_order_attribution_device_type' ); $referrer = (string) $order->get_meta( '_wc_order_attribution_referrer' ); $utm_source = (string) $order->get_meta( '_wc_order_attribution_utm_source' ); $agent = $this->match_agent( $ua ); // ---- TIER 1: verified signed-mandate checkout ------------------------ $verified = $this->verify( $order ); if ( ! empty( $verified['verified'] ) ) { $source = '' !== $verified['source'] ? $verified['source'] : ( '' !== $agent ? $agent : 'agent' ); return array( 'source' => $source, 'tier' => self::TIER_VERIFIED, 'confidence' => 'verified', 'signals' => array( 'signed_mandate' ), 'mandate' => (string) $verified['mandate'], ); } // ---- TIER 2: inferred heuristics ------------------------------------- $signals = array(); if ( '' !== $agent ) { $signals[] = 'agent_ua'; } $is_browser = ( '' !== $ua ) && (bool) preg_match( '/Mozilla|Chrome|Safari|Firefox|Edg\/|OPR\/|Opera/i', $ua ); $api_channel = in_array( $created_via, array( 'store-api', 'rest-api', 'rest_api' ), true ); if ( $api_channel ) { $signals[] = 'api_channel'; } if ( '' === $ua ) { $signals[] = 'empty_ua'; } elseif ( ! $is_browser ) { $signals[] = 'non_browser_ua'; } if ( '' !== $device && 0 === strcasecmp( $device, 'Bot' ) ) { $signals[] = 'attr_bot'; } // Referrer / utm_source can name an AI platform even when the UA is generic. $ref_agent = $this->match_referrer( $referrer . ' ' . $utm_source ); if ( '' !== $ref_agent ) { $signals[] = 'agent_referrer'; if ( '' === $agent ) { $agent = $ref_agent; } } /** * Filter the inferred-tier detection signals for an order, so add-ons can * contribute extra heuristic evidence. (Verified-tier assertions use the * `arwc_agent_verified_order` filter instead — see {@see verify()}.) * * @param string[] $signals Signal codes gathered so far. * @param WC_Order $order The order. */ $signals = array_values( array_unique( (array) apply_filters( 'arwc_agent_detection', $signals, $order ) ) ); $has = static function ( $code ) use ( $signals ) { return in_array( $code, $signals, true ); }; $confidence = ''; $source = 'human'; if ( $has( 'agent_ua' ) ) { $source = '' !== $agent ? $agent : 'agent'; $confidence = 'high'; } elseif ( $api_channel && ( $has( 'empty_ua' ) || $has( 'non_browser_ua' ) ) ) { $source = '' !== $agent ? $agent : 'agent'; $confidence = 'high'; } elseif ( $has( 'attr_bot' ) || $has( 'agent_referrer' ) ) { $source = '' !== $agent ? $agent : 'agent'; $confidence = 'medium'; } elseif ( $has( 'non_browser_ua' ) ) { $source = '' !== $agent ? $agent : 'agent'; $confidence = 'low'; } if ( 'human' === $source ) { return array( 'source' => 'human', 'tier' => '', 'confidence' => '', 'signals' => array(), 'mandate' => '', ); } return array( 'source' => $source, 'tier' => self::TIER_INFERRED, 'confidence' => $confidence, 'signals' => $signals, 'mandate' => '', ); } /** * TIER 1 detection: was this order placed through an agent checkout carrying a * signed payment mandate (UCP/ACP + AP2)? * * This is the provable path, so it only ever fires from a concrete artifact — * never a heuristic: * 1. An explicit assertion from the checkout/payment layer that actually * validated the mandate signature, via the `arwc_agent_verified_order` * filter. That layer (an ACP/UCP integration, or a future AgenticTrack * checkout endpoint) passes `[ 'source' => slug, 'mandate' => ref ]`. * 2. A recorded mandate credential in the order meta (a paid order that stored * an AP2/ACP/UCP mandate — the gateway accepted it, so it was validated). * 3. A mandate credential presented on the checkout request headers. * * We store only a short marker as the mandate reference, never the raw signed * credential. Returns not-verified when no such signal exists (the normal case * for stores with no agent-checkout traffic yet — that is correct, not a bug). * * @param WC_Order $order Order. * @return array{verified:bool,source:string,mandate:string} */ public function verify( WC_Order $order ) { /** * Assert a verified agent order from the checkout/payment layer that * validated the mandate. Return an array with a `mandate` reference (and * optional `source` slug), or boolean true, to mark the order Tier-1 * verified. Return null/false to leave detection to the other paths. * * @param array|bool|null $assertion Verification assertion. * @param WC_Order $order The order. */ $assertion = apply_filters( 'arwc_agent_verified_order', null, $order ); if ( is_array( $assertion ) && ! empty( $assertion['mandate'] ) ) { return array( 'verified' => true, 'source' => isset( $assertion['source'] ) ? sanitize_key( $assertion['source'] ) : 'agent', 'mandate' => substr( sanitize_text_field( (string) $assertion['mandate'] ), 0, 128 ), ); } if ( true === $assertion ) { return array( 'verified' => true, 'source' => 'agent', 'mandate' => 'asserted', ); } // Recorded mandate credential on the order (stored by the checkout layer). foreach ( $this->mandate_meta_keys() as $key ) { if ( '' !== (string) $order->get_meta( $key ) ) { return array( 'verified' => true, 'source' => 'agent', 'mandate' => 'meta:' . $key, ); } } // Mandate credential presented on the request headers. $header = $this->request_mandate_header(); if ( '' !== $header ) { return array( 'verified' => true, 'source' => 'agent', 'mandate' => 'header:' . $header, ); } return array( 'verified' => false, 'source' => '', 'mandate' => '', ); } /** * Order-meta keys under which a UCP/ACP/AP2 checkout integration records a * signed payment mandate. Filterable so any integration can register its own. * * @return string[] */ protected function mandate_meta_keys() { $keys = array( '_ap2_payment_mandate', '_ap2_mandate', '_acp_payment_mandate', '_acp_checkout_session', '_ucp_payment_mandate', '_ucp_session', '_agent_payment_mandate', ); /** * Filter the order-meta keys that indicate a signed agent-payment mandate. * * @param string[] $keys Meta keys. */ return (array) apply_filters( 'arwc_agent_mandate_meta_keys', $keys ); } /** * If the current request carries a signed agent-payment mandate header, return * that header's name (never its value). Filterable. * * @return string */ protected function request_mandate_header() { $headers = array( 'HTTP_AP2_MANDATE', 'HTTP_X_AP2_MANDATE', 'HTTP_ACP_MANDATE', 'HTTP_X_ACP_MANDATE', 'HTTP_UCP_MANDATE', 'HTTP_X_UCP_MANDATE', 'HTTP_AGENT_PAYMENT_MANDATE', 'HTTP_X_AGENT_PAYMENT_MANDATE', ); /** * Filter the request headers that carry a signed agent-payment mandate. * * @param string[] $headers `$_SERVER` header keys. */ $headers = (array) apply_filters( 'arwc_agent_mandate_headers', $headers ); foreach ( $headers as $key ) { // Presence check only; the value (a signed credential) is never stored. if ( isset( $_SERVER[ $key ] ) && '' !== trim( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ) ) { return $key; } } return ''; } /** * Known agent user-agent patterns → source slug. * * @return array regex (no delimiters) => slug. */ protected function agent_patterns() { $patterns = array( 'ChatGPT-Agent|ChatGPT-User|GPTBot|OAI-SearchBot|OAI-|OpenAI' => 'openai', 'Google-UCPAgent|Google-Extended|GoogleAgent|GoogleOther|UCPAgent' => 'google', 'PerplexityBot|Perplexity-User|Perplexity' => 'perplexity', 'ClaudeBot|Claude-User|Claude-SearchBot|Claude-Agent|anthropic' => 'anthropic', 'Amazonbot' => 'amazon', 'Applebot' => 'apple', 'Meta-ExternalAgent|meta-externalfetcher|FacebookBot' => 'meta', 'Bytespider' => 'bytedance', ); /** * Filter the agent user-agent → source-slug patterns. * * @param array $patterns Regex => slug. */ return (array) apply_filters( 'arwc_agent_ua_patterns', $patterns ); } /** * Match a specific agent from a user-agent string, or a generic-agent slug for * obvious non-browser HTTP clients, else ''. * * @param string $ua User-agent. * @return string Slug or ''. */ protected function match_agent( $ua ) { if ( '' === $ua ) { return ''; } foreach ( $this->agent_patterns() as $regex => $slug ) { if ( preg_match( '/' . $regex . '/i', $ua ) ) { return $slug; } } // Generic automation clients (still an "agent", just not identified). if ( preg_match( '/\b(bot|agent|crawler|spider)\b|python-requests|node-fetch|axios|Go-http-client|okhttp|libwww|curl|wget|HeadlessChrome|PhantomJS|Puppeteer|Playwright/i', $ua ) ) { return 'agent'; } return ''; } /** * Match an AI platform from a referrer / utm_source string. * * @param string $text Referrer + utm source. * @return string Slug or ''. */ protected function match_referrer( $text ) { $text = strtolower( $text ); if ( '' === trim( $text ) ) { return ''; } $map = array( 'chatgpt.com|openai.com|chat.openai' => 'openai', 'perplexity.ai' => 'perplexity', 'claude.ai|anthropic.com' => 'anthropic', 'gemini.google|bard.google|google.com/search?udm' => 'google', 'copilot.microsoft|bing.com/chat' => 'microsoft', ); foreach ( $map as $regex => $slug ) { if ( preg_match( '/' . $regex . '/i', $text ) ) { return $slug; } } return ''; } /** * Human label for a source slug. * * @param string $slug Source slug. * @return string */ public static function source_label( $slug ) { $labels = array( 'openai' => __( 'ChatGPT (OpenAI)', 'agentictrack-agent-readiness-for-woocommerce' ), 'google' => __( 'Google', 'agentictrack-agent-readiness-for-woocommerce' ), 'perplexity' => __( 'Perplexity', 'agentictrack-agent-readiness-for-woocommerce' ), 'anthropic' => __( 'Claude (Anthropic)', 'agentictrack-agent-readiness-for-woocommerce' ), 'amazon' => __( 'Amazon', 'agentictrack-agent-readiness-for-woocommerce' ), 'apple' => __( 'Apple', 'agentictrack-agent-readiness-for-woocommerce' ), 'meta' => __( 'Meta', 'agentictrack-agent-readiness-for-woocommerce' ), 'bytedance' => __( 'ByteDance', 'agentictrack-agent-readiness-for-woocommerce' ), 'microsoft' => __( 'Microsoft Copilot', 'agentictrack-agent-readiness-for-woocommerce' ), 'agent' => __( 'AI agent (unspecified)', 'agentictrack-agent-readiness-for-woocommerce' ), 'human' => __( 'Human / browser', 'agentictrack-agent-readiness-for-woocommerce' ), ); return isset( $labels[ $slug ] ) ? $labels[ $slug ] : $slug; } /** * Read the recorded agent source for an order. * * @param int $order_id Order ID. * @return string Source slug, or '' if not attributed. */ public function get_agent_source( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order instanceof WC_Order ) { return ''; } return (string) $order->get_meta( self::META_AGENT_SOURCE ); } /** * Whether an order is attributed to an AI agent (i.e. not human/unattributed). * * @param WC_Order $order Order. * @return bool */ public static function is_agent_order( WC_Order $order ) { $source = (string) $order->get_meta( self::META_AGENT_SOURCE ); return '' !== $source && 'human' !== $source; } /** * Invalidate the cached dashboard stats. * * @return void */ public function flush_stats() { foreach ( array( 30, 90, 365 ) as $days ) { delete_transient( self::STATS_TRANSIENT . '_' . $days ); } } /** * Aggregate agent-attributed orders over a recent window for the dashboard. * * Best-effort and bounded: it reads at most STATS_MAX recent paid orders, so on * very large stores the figures reflect the most recent orders in the window. * Cached briefly to keep the admin page snappy. * * @param int $days Window in days (default 90). * @return array */ public function get_stats( $days = 90 ) { $days = max( 1, (int) $days ); $cache_key = self::STATS_TRANSIENT . '_' . $days; $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $orders = wc_get_orders( array( 'type' => 'shop_order', 'status' => wc_get_is_paid_statuses(), 'date_created' => '>=' . ( time() - $days * DAY_IN_SECONDS ), 'limit' => self::STATS_MAX, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'objects', 'paginate' => false, ) ); $orders = is_array( $orders ) ? $orders : array(); $total_orders = 0; $agent_orders = 0; $total_revenue = 0.0; $agent_revenue = 0.0; $verified_orders = 0; $inferred_orders = 0; $verified_revenue = 0.0; $inferred_revenue = 0.0; $by_agent = array(); $daily = array(); foreach ( $orders as $order ) { if ( ! $order instanceof WC_Order ) { continue; } $total_orders++; $total_revenue += (float) $order->get_total(); if ( ! self::is_agent_order( $order ) ) { continue; } $source = (string) $order->get_meta( self::META_AGENT_SOURCE ); $revenue = (float) $order->get_total(); $agent_orders++; $agent_revenue += $revenue; if ( self::TIER_VERIFIED === (string) $order->get_meta( self::META_TIER ) ) { $verified_orders++; $verified_revenue += $revenue; } else { $inferred_orders++; $inferred_revenue += $revenue; } if ( ! isset( $by_agent[ $source ] ) ) { $by_agent[ $source ] = array( 'label' => self::source_label( $source ), 'count' => 0, 'revenue' => 0.0, ); } $by_agent[ $source ]['count']++; $by_agent[ $source ]['revenue'] += $revenue; $created = $order->get_date_created(); if ( $created ) { $day = $created->date( 'Y-m-d' ); $daily[ $day ] = isset( $daily[ $day ] ) ? $daily[ $day ] + 1 : 1; } } uasort( $by_agent, static function ( $a, $b ) { return $b['count'] <=> $a['count']; } ); $stats = array( 'window_days' => $days, 'total_orders' => $total_orders, 'agent_orders' => $agent_orders, 'verified_orders' => $verified_orders, 'inferred_orders' => $inferred_orders, 'total_revenue' => $total_revenue, 'agent_revenue' => $agent_revenue, 'verified_revenue' => $verified_revenue, 'inferred_revenue' => $inferred_revenue, 'pct' => $total_orders > 0 ? round( $agent_orders / $total_orders * 100, 1 ) : 0.0, 'by_agent' => $by_agent, 'timeline' => $this->build_timeline( $daily, min( 30, max( 7, $days ) ) ), 'timeline_days' => min( 30, max( 7, $days ) ), 'capped' => count( $orders ) >= self::STATS_MAX, 'currency' => html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ), ); set_transient( $cache_key, $stats, 15 * MINUTE_IN_SECONDS ); return $stats; } /** * List the individual agent-attributed orders in a window, for CSV export (Pro). * No customer PII — order number, date, tier, agent, confidence, amount, currency. * Bounded by STATS_MAX like get_stats(). * * @param int $days Window in days. * @return array> */ public function get_agent_order_rows( $days = 90 ) { $days = max( 1, (int) $days ); $orders = wc_get_orders( array( 'type' => 'shop_order', 'status' => wc_get_is_paid_statuses(), 'date_created' => '>=' . ( time() - $days * DAY_IN_SECONDS ), 'limit' => self::STATS_MAX, 'orderby' => 'date', 'order' => 'DESC', 'return' => 'objects', 'paginate' => false, ) ); $orders = is_array( $orders ) ? $orders : array(); $rows = array(); foreach ( $orders as $order ) { if ( ! $order instanceof WC_Order || ! self::is_agent_order( $order ) ) { continue; } $created = $order->get_date_created(); $rows[] = array( 'order' => $order->get_order_number(), 'date' => $created ? $created->date( 'Y-m-d H:i' ) : '', 'tier' => ( self::TIER_VERIFIED === (string) $order->get_meta( self::META_TIER ) ) ? 'verified' : 'inferred', 'agent' => self::source_label( (string) $order->get_meta( self::META_AGENT_SOURCE ) ), 'confidence' => (string) $order->get_meta( self::META_CONFIDENCE ), 'amount' => (string) $order->get_total(), 'currency' => $order->get_currency(), ); } return $rows; } /** * Build a zero-filled daily timeline (last $span days, oldest first). * * @param array $daily Map of Y-m-d => agent-order count. * @param int $span Number of days. * @return array */ protected function build_timeline( array $daily, $span ) { $out = array(); for ( $i = $span - 1; $i >= 0; $i-- ) { $date = gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ); $out[] = array( 'date' => $date, 'count' => isset( $daily[ $date ] ) ? (int) $daily[ $date ] : 0, ); } return $out; } }