* { eventName:"checkout_completed", orderId, visitorKey|null, customerId|null, * productIds:[...], value, currency } * * Server-side is the right place for the order event: it is ad-blocker-proof and * fires even if the shopper's tab is closed (unlike a client pixel). The brain * correlates it to prior try-ons by visitorKey + customerId + productId within the * 30-day attribution window. The secret key is server-only (never exposed to the * browser). Everything here is best-effort — a checkout must NEVER fail because the * brain is slow or unreachable. * * @package Aisthetix\TryOn */ namespace Aisthetix\TryOn; defined( 'ABSPATH' ) || exit; /** * Order attribution sender. */ class Attribution { /** * Register hooks. * * @return void */ public function init() { // Fires once payment is captured for an order (the reliable "purchased" signal). add_action( 'woocommerce_payment_complete', array( $this, 'on_payment_complete' ), 20, 1 ); } /** * Build and send the checkout_completed event for a paid order. * * @param int $order_id WooCommerce order id. * @return void */ public function on_payment_complete( $order_id ) { // Only when connected (we need the secret key to authenticate). if ( ! Plugin::is_connected() ) { return; } // Opt-in analytics: no server-side order event without the merchant's explicit // consent (default OFF). Mirrors the storefront cookie/event gate. if ( ! Plugin::analytics_enabled() ) { return; } $order_id = (int) $order_id; if ( $order_id <= 0 ) { return; } $order = wc_get_order( $order_id ); if ( ! $order instanceof \WC_Order ) { return; } $payload = $this->build_payload( $order ); // Fire-and-forget: non-blocking so checkout latency is unaffected. The brain // is idempotent on (storeId, order:), so a retry can't double-count. wp_remote_post( Plugin::brain_url() . '/api/storefront/order', array( 'timeout' => 5, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Aisthetix-Secret' => Plugin::secret_key(), ), 'body' => wp_json_encode( $payload ), ) ); } /** * Assemble the order event payload (matches the shared contract exactly). * * @param \WC_Order $order Paid order. * @return array */ private function build_payload( $order ) { $product_ids = array(); foreach ( $order->get_items() as $item ) { if ( ! $item instanceof \WC_Order_Item_Product ) { continue; } // Canonical product id (not the variation id) — the join key the brain // matches against try-on / view anchors. The brain de-dupes productIds[]. $pid = (int) $item->get_product_id(); if ( $pid > 0 ) { $product_ids[] = (string) $pid; } } $product_ids = array_values( array_unique( $product_ids ) ); // customerId: the logged-in WP user id on the order, or null for guests. $customer_id = (int) $order->get_customer_id(); return array( 'eventName' => 'checkout_completed', 'orderId' => (string) $order->get_id(), 'visitorKey' => $this->visitor_key_from_cookie(), 'customerId' => $customer_id > 0 ? (string) $customer_id : null, 'productIds' => $product_ids, // REVENUE BASIS (intentional): the order value is the GROSS order total — // get_total() includes tax and shipping. This is a deliberate choice for // parity with the Shopify attribution (which reports the order's total // price), so attributedRevenue means the same thing across platforms and // the brain's ROI numbers are comparable. It is NOT a bug to "fix" to a // net/subtotal basis; doing so would diverge from Shopify. If a net basis // is ever wanted it must be changed on BOTH platforms together. 'value' => (float) $order->get_total(), 'currency' => $order->get_currency(), ); } /** * Read the durable first-party visitor key from the `aisthetix_vid` cookie. * * The cookie is set by {@see Storefront::maybe_set_visitor_cookie()} on product * pages and is sent on the same first-party domain at checkout, so it is the * PRIMARY attribution key. Returns null when absent or malformed (a guest who * never hit a product page, or a stripped cookie) — the brain still attributes * via customerId for logged-in shoppers. * * @return string|null */ private function visitor_key_from_cookie() { if ( ! isset( $_COOKIE[ Storefront::VISITOR_COOKIE ] ) ) { return null; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by the strict regex below. $candidate = wp_unslash( $_COOKIE[ Storefront::VISITOR_COOKIE ] ); if ( is_string( $candidate ) && preg_match( '/^[a-f0-9]{32}$/', $candidate ) ) { return $candidate; } return null; } }