, utm_medium=personal_agent, so * WooCommerce's own Order Attribution labels the order "Source: " * in the Orders list and its reports, with no UI of ours; * - the order also gets authoritative server-side meta, and, where * WooCommerce recorded no attribution (the shopper's browser didn't run * its attribution script), the same values through WooCommerce's public * `woocommerce_order_save_attribution_data` hook. That fallback is behind * `agentadapt_attribution_fallback` pending the consent review in §12.1. * * It is advertised as the domain-named extension * AGENTADAPT_EXTENSION_DOMAIN/checkout-handoff (spec §3.3). * * @package AgentAdapt */ defined( 'ABSPATH' ) || exit; /** * Checkout handoff. */ final class AgentAdapt_Handoff { public const TTL = 30 * MINUTE_IN_SECONDS; private const PREFIX = 'agc_ho_'; private const SESSION_KEY = 'agentadapt_handoff'; /** * Hook registration. */ public static function init(): void { add_filter( 'agentadapt_extensions', array( __CLASS__, 'advertise' ) ); add_action( 'woocommerce_checkout_order_created', array( __CLASS__, 'attribute_order' ), 20 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( __CLASS__, 'attribute_order' ), 20 ); } /** * Register cart.handoff. */ public static function register(): void { AgentAdapt_Capabilities::register( 'cart.handoff', array( 'summary' => __( 'Hand the cart to the customer for checkout', 'agent-adapt' ), 'description' => __( 'Returns a single-use link, valid for 30 minutes, that the customer opens in their own browser. It adds this cart to their cart there and opens checkout, where they pay. The link carries no account access.', 'agent-adapt' ), 'method' => 'POST', 'path' => '/cart/handoff', 'status' => 201, 'response' => array( 'type' => 'object', 'properties' => array( 'url' => array( 'type' => 'string', 'format' => 'uri' ), 'expires_at' => array( 'type' => 'string', 'format' => 'date-time' ), 'item_count' => array( 'type' => 'integer' ), ), ), 'handler' => array( __CLASS__, 'create' ), ) ); } /** * Advertise the extension. * * @param array> $ext Extensions. * @return array> */ public static function advertise( array $ext ): array { $ext[ AgentAdapt_Extensions::name( 'checkout-handoff' ) ] = array( 'version' => '1', 'capability' => 'cart.handoff', ); return $ext; } /** * cart.handoff * * @param AgentAdapt_Context $context Context. * @param array $p Params (none). * @return array|WP_Error */ public static function create( AgentAdapt_Context $context, array $p ) { AgentAdapt_Cart::bind( $context->wc_session_key ); $count = (int) WC()->cart->get_cart_contents_count(); if ( $count < 1 ) { return new WP_Error( 'cart_empty', 'there is nothing in the cart to hand off', array( 'status' => 409 ) ); } global $wpdb; $token = self::PREFIX . AgentAdapt_Tokens::random( 24 ); $expires = time() + self::TTL; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'tokens' ), array( 'token_hash' => hash( 'sha256', $token ), 'session_id' => $context->session_id, 'kind' => 'handoff', 'scope' => '', 'expires' => $expires ) ); AgentAdapt_Events::record( 'cart.handoff', $context, array( 'value' => (float) WC()->cart->get_total( 'edit' ), 'meta' => array( 'items' => $count ) ) ); return array( 'url' => AgentAdapt_Urls::url( '/' . AgentAdapt_Urls::BASE . '/cart/' . $token ), 'expires_at' => gmdate( 'c', $expires ), 'item_count' => $count, ); } /** * GET /agent-adapt/cart/{token}, in the CUSTOMER's browser. * * @param string $token Token from the path. */ public static function redeem( string $token ): void { nocache_headers(); if ( 'GET' !== AgentAdapt_Http::method() ) { self::page( 405, __( 'This link opens in a browser.', 'agent-adapt' ) ); } $session = self::consume( $token ); if ( null === $session ) { self::page( 410, __( 'This checkout link has expired or was already used. Ask your assistant for a new one.', 'agent-adapt' ) ); } $agent_cart = self::stored_cart( (string) $session['wc_session_key'] ); wc_load_cart(); // The browser's own WooCommerce session and cart. $added = 0; foreach ( (array) ( $agent_cart['cart'] ?? array() ) as $line ) { $pid = (int) ( $line['product_id'] ?? 0 ); if ( $pid < 1 || ! AgentAdapt_Catalog::resolvable( (int) ( $line['variation_id'] ?? 0 ) ?: $pid ) ) { continue; } if ( WC()->cart->add_to_cart( $pid, max( 1, (int) ( $line['quantity'] ?? 1 ) ), (int) ( $line['variation_id'] ?? 0 ), (array) ( $line['variation'] ?? array() ) ) ) { ++$added; } } foreach ( (array) ( $agent_cart['applied_coupons'] ?? array() ) as $code ) { if ( ! WC()->cart->has_discount( $code ) ) { WC()->cart->apply_coupon( $code ); } } wc_clear_notices(); $agent = self::agent_name( (string) $session['client_hash'] ); WC()->session->set( self::SESSION_KEY, array( 'session_id' => (string) $session['session_id'], 'client_hash' => (string) $session['client_hash'], 'subject_id' => (int) $session['subject_id'], 'agent' => $agent, 'at' => time(), ) ); WC()->session->set_customer_session_cookie( true ); WC()->cart->calculate_totals(); AgentAdapt_Events::record( 'handoff.redeemed', null, array( 'client_hash' => (string) $session['client_hash'], 'subject_id' => (int) $session['subject_id'], 'session_id' => (string) $session['session_id'], 'channel' => 'web', 'value' => $added ) ); $to = add_query_arg( array( 'utm_source' => rawurlencode( '' !== $agent ? $agent : 'personal agent' ), 'utm_medium' => 'personal_agent', 'utm_campaign' => 'agent-adapt', ), wc_get_checkout_url() ); wp_safe_redirect( $to, 302, 'Agent Adapt' ); exit; } /** * Atomically use a handoff token. The Session it points at, or null. * * @param string $token Token. * @return array|null */ private static function consume( string $token ): ?array { if ( ! str_starts_with( $token, self::PREFIX ) || strlen( $token ) > 80 ) { return null; } global $wpdb; $t = AgentAdapt_Schema::table( 'tokens' ); $hash = hash( 'sha256', $token ); // phpcs:disable WordPress.DB.DirectDatabaseQuery $sid = $wpdb->get_var( $wpdb->prepare( "SELECT session_id FROM %i WHERE token_hash = %s AND kind = 'handoff' AND expires >= %d", $t, $hash, time() ) ); if ( ! $sid ) { return null; } // Only the request whose DELETE removes the row may proceed. $deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE token_hash = %s AND kind = 'handoff'", $t, $hash ) ); // phpcs:enable if ( 1 !== (int) $deleted ) { return null; } return AgentAdapt_Sessions::get( (string) $sid ); } /** * The agent Session's stored WooCommerce session data. * * @param string $key WooCommerce session key. * @return array */ private static function stored_cart( string $key ): array { $data = AgentAdapt_WC_Session::stored( array( $key ) )[ $key ] ?? array(); return array( 'cart' => (array) ( $data['cart'] ?? array() ), 'applied_coupons' => (array) ( $data['applied_coupons'] ?? array() ), ); } /** * The agent's display name, as its metadata states it. * * @param string $client_hash Agent hash. */ private static function agent_name( string $client_hash ): string { global $wpdb; $t = AgentAdapt_Schema::table( 'agents' ); return (string) $wpdb->get_var( $wpdb->prepare( "SELECT name FROM %i WHERE client_hash = %s", $t, $client_hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Mark an order placed after a handoff (classic and block checkout). * * @param WC_Order $order Order. */ public static function attribute_order( $order ): void { if ( ! $order instanceof WC_Order || ! WC()->session ) { return; } $flag = WC()->session->get( self::SESSION_KEY ); if ( ! is_array( $flag ) || empty( $flag['session_id'] ) || $order->get_meta( '_agentadapt_session_id' ) ) { return; } $order->update_meta_data( '_agentadapt_session_id', (string) $flag['session_id'] ); $order->update_meta_data( '_agentadapt_client_hash', (string) $flag['client_hash'] ); $order->update_meta_data( '_agentadapt_agent', (string) $flag['agent'] ); $order->save(); /** * Whether to record WooCommerce attribution server-side when the * shopper's browser recorded none (PLAN.md §12.1, pending review). * * @param bool $fallback Default true. * @param WC_Order $order The order. */ if ( '' === (string) $order->get_meta( '_wc_order_attribution_source_type' ) && apply_filters( 'agentadapt_attribution_fallback', true, $order ) ) { // WooCommerce's own public hook (since 8.5), invoked on purpose: not ours to prefix. do_action( 'woocommerce_order_save_attribution_data', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $order, self::attribution_values( array( 'source_type' => 'utm', 'utm_source' => '' !== (string) $flag['agent'] ? (string) $flag['agent'] : 'personal agent', 'utm_medium' => 'personal_agent', 'utm_campaign' => 'agent-adapt', ) ) ); } AgentAdapt_Events::record( 'order.placed', null, array( 'client_hash' => (string) $flag['client_hash'], 'subject_id' => (int) $flag['subject_id'], 'session_id' => (string) $flag['session_id'], 'channel' => 'web', 'object_id' => $order->get_id(), 'value' => (float) $order->get_total() ) ); WC()->session->set( self::SESSION_KEY, null ); } /** * A complete attribution payload. WooCommerce reads EVERY field it knows * without checking it is set, so a partial array logs a warning per * missing field (found by the integration suite, WooCommerce 11.2.1). The * field list comes from WooCommerce when it exposes it, so new fields are * covered without a release of ours. * * @param array $values Fields we set. * @return array */ private static function attribution_values( array $values ): array { $fields = array( 'source_type', 'referrer', 'utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_id', 'utm_term', 'utm_source_platform', 'utm_creative_format', 'utm_marketing_tactic', 'session_entry', 'session_start_time', 'session_pages', 'session_count', 'user_agent' ); $class = '\\Automattic\\WooCommerce\\Internal\\Orders\\OrderAttributionController'; try { if ( function_exists( 'wc_get_container' ) && class_exists( $class ) ) { $names = wc_get_container()->get( $class )->get_field_names(); if ( is_array( $names ) && $names ) { $fields = array_values( array_map( 'strval', $names ) ); } } } catch ( Throwable $e ) { // Fall back to the list above. } return array_merge( array_fill_keys( $fields, '' ), $values ); } /** * A minimal page for the customer, then stop. * * @param int $status HTTP status. * @param string $message Message. */ private static function page( int $status, string $message ): void { wp_die( '

' . esc_html( $message ) . '

' . esc_html__( 'Continue to the shop', 'agent-adapt' ) . '

', esc_html( get_bloginfo( 'name' ) ), array( 'response' => (int) $status ) ); } }