get_price(); $currency = AdvisorPPC_Events::currency( function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '' ); $event_id = AdvisorPPC_Events::record( 'add_to_cart', array( 'value' => $price * $quantity, 'currency' => $currency, 'user_data' => self::current_user_data(), ) ); self::mirror_cart_event( 'add_to_cart', array( 'value' => $price * $quantity, 'currency' => $currency, 'items' => array( array( 'item_id' => (string) $product->get_id(), 'item_name' => method_exists( $product, 'get_name' ) ? (string) $product->get_name() : '', 'quantity' => $quantity, 'price' => $price, ), ), ), $event_id ); } /** * begin_checkout via template_redirect, covering the checkout block. * * @return void */ public static function maybe_begin_checkout() { if ( ! self::is_active() || ! function_exists( 'is_checkout' ) ) { return; } if ( ! is_checkout() ) { return; } if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) { return; } self::on_begin_checkout(); } /** * begin_checkout. * * @return void */ public static function on_begin_checkout() { if ( isset( self::$fired['begin_checkout'] ) ) { return; } if ( ! self::ready() ) { return; } self::$fired['begin_checkout'] = true; $cart = ( function_exists( 'WC' ) && isset( WC()->cart ) ) ? WC()->cart : null; if ( ! $cart || $cart->is_empty() ) { return; } $currency = AdvisorPPC_Events::currency( function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '' ); $event_id = AdvisorPPC_Events::record( 'begin_checkout', array( 'value' => $cart->get_total( 'edit' ), 'currency' => $currency, 'user_data' => self::current_user_data(), ) ); self::mirror_cart_event( 'begin_checkout', array( 'value' => (float) $cart->get_total( 'edit' ), 'currency' => $currency, ), $event_id ); } /** * Print the browser half of a cart event, carrying the server's event_id. * * The server half of add_to_cart and begin_checkout is keyed on the * visitor's session (CONTRACT: order_or_session_id), so its id is * deterministic: wp_sha1( site_url | event_name | session_id ). The browser * half has to carry that exact value or Meta, GA4, TikTok and Pinterest * receive the same cart action twice under two ids and count it twice. * AdvisorPPC_Tags never derives the id itself for these two; it takes the * one the queue was given. A theme that calls advisorppc.track() for the * same names re-derives the same value in the browser from the shared * session cookie (public/js/tags.js, serverTwinEventId). * * Nothing is printed when the server half was not queued: without an id to * share there is nothing to deduplicate against, and a browser-minted id * here would be the double count this exists to prevent. * * @param string $name add_to_cart|begin_checkout. * @param array $params Browser parameters (value, currency, items). * @param string|false $event_id The id AdvisorPPC_Events::record() returned. * @return void */ protected static function mirror_cart_event( $name, $params, $event_id ) { if ( ! is_string( $event_id ) || '' === $event_id ) { return; } if ( ! class_exists( 'AdvisorPPC_Tags' ) || ! method_exists( 'AdvisorPPC_Tags', 'enqueue_browser_event' ) ) { return; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return; } // The live add-to-cart paths are an AJAX call (wc-ajax=add_to_cart, or // the legacy admin-ajax.php route, where is_admin() is true) and a // Store API POST. Neither renders a footer, so AdvisorPPC_Tags hands // the event to the browser through its pending cookie instead. Only a // real wp-admin screen has no visitor to fire it for. $doing_ajax = function_exists( 'wp_doing_ajax' ) && wp_doing_ajax(); if ( is_admin() && ! $doing_ajax ) { return; } AdvisorPPC_Tags::enqueue_browser_event( $name, $params, $event_id ); } /** * Persist click IDs and the session id onto the order. * * Runs while the order object is still in memory, before save, so this * costs no extra write. * * @param mixed $order WC_Order. * @return void */ public static function stamp_order( $order ) { if ( ! self::is_active() || ! is_object( $order ) || ! method_exists( $order, 'update_meta_data' ) ) { return; } if ( ! class_exists( 'AdvisorPPC_Events' ) ) { return; } $click_ids = array_filter( AdvisorPPC_Events::click_ids(), function ( $value ) { return null !== $value && '' !== $value; } ); if ( ! empty( $click_ids ) ) { $order->update_meta_data( self::META_CLICK_IDS, wp_json_encode( $click_ids ) ); } $session_id = AdvisorPPC_Events::session_id(); if ( '' !== $session_id ) { $order->update_meta_data( self::META_SESSION_ID, $session_id ); } } /** * purchase. * * Idempotent: the first of woocommerce_payment_complete / woocommerce_thankyou * to arrive wins, the other is a no-op. * * @param int $order_id Order id. * @return void */ public static function on_purchase( $order_id = 0 ) { $order_id = (int) $order_id; if ( $order_id <= 0 || ! self::is_active() || ! function_exists( 'wc_get_order' ) ) { return; } if ( isset( self::$fired[ 'purchase_' . $order_id ] ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order || ! method_exists( $order, 'get_total' ) ) { return; } $status = (string) $order->get_status(); if ( in_array( $status, self::NEVER_STATUSES, true ) ) { return; } $unpaid = in_array( $status, self::DEAD_STATUSES, true ); /** * Filter whether the browser half of a purchase fires for an order * that has not been paid yet (on-hold: bank transfer, cheque). * * Off by default: a Google Ads conversion action and a GA4 purchase * for an unpaid order are conversions with no money behind them, and * they do not collapse against the server event sent when the order is * later marked paid. A store whose manual-payment orders are reliably * paid can switch this on and accept that. * * @since 0.1.0 * @param bool $fire Default false. * @param object $order WC_Order. * @param string $status Order status. */ $fire_unpaid = (bool) apply_filters( 'advisorppc_browser_purchase_unpaid', false, $order, $status ); // The browser twin runs BEFORE the server once-guard, and deliberately // so. The common gateway flow is: an IPN fires woocommerce_payment_complete // with no browser present, which records the server event and stamps the // order; the customer then lands on the thank-you page, where the guard // below would return early. If the mirror sat after that guard, the one // request that actually has a browser in it would never print the id, // which is precisely the double-count this whole mechanism exists to // prevent. mirror_purchase() has its own per-request guard and the id is // deterministic, so calling it on both hooks is safe. // // It does NOT run for an unpaid order. No money, no conversion, on // either half; see NEVER_STATUSES for why. // // 'skipped' is the honest default. A bank-transfer or cheque order // reaches this hook again days later, when the shop marks it paid from // wp-admin, and the customer's browser is not reachable from there. $browser_half = 'skipped'; if ( ! $unpaid || $fire_unpaid ) { $browser_half = self::mirror_purchase( $order, $order_id, AdvisorPPC_Events::currency( $order->get_currency() ) ) ? 'sent' : 'skipped'; } // Everything past here is the SERVER half, which does need a connected // workspace and the WooCommerce module. The browser half above does not: // it belongs to the free tag module, and a tags-only store still needs // the thank-you page to carry an id, or a later CAPI send from the same // workspace could never be collapsed against it. // // An 'on-hold' order stops here. The money has not arrived, so the // server must not tell the platforms it has; the sale is recorded when // the shop marks it paid, on the status hooks in register_hooks(). if ( $unpaid ) { return; } if ( ! self::ready() ) { return; } if ( $order->get_meta( self::META_PURCHASE_SENT ) ) { return; } self::$fired[ 'purchase_' . $order_id ] = true; // Late stamp: covers gateways and flows where checkout_create_order never // ran, for example an order created by a payment redirect handler. $click_ids = self::order_click_ids( $order ); if ( empty( $click_ids ) ) { self::stamp_order( $order ); $click_ids = self::order_click_ids( $order ); } $currency = AdvisorPPC_Events::currency( $order->get_currency() ); $recorded = AdvisorPPC_Events::record( 'purchase', array( 'order_ref' => self::order_ref( $order_id ), 'value' => $order->get_total(), 'currency' => $currency, 'occurred_at' => self::order_timestamp( $order ), 'session_id' => self::order_session_id( $order ), 'click_ids' => $click_ids, 'user_data' => self::order_user_data( $order ), // Local only. Never part of the event envelope; it is recorded // on the queue row so a CAPI-only sale can be told apart from // one whose pixel also fired. 'browser_half' => $browser_half, ) ); if ( $recorded ) { $order->update_meta_data( self::META_PURCHASE_SENT, time() ); $order->save(); } } /** * Print the browser half of a purchase, carrying the server's event_id. * * Only fires on a real page view. On a gateway callback * (woocommerce_payment_complete during an IPN) there is no browser to print * into, and the thank-you page will call this again for the same order; the * per-request guard below plus the deterministic id make that safe. * * A bank-transfer or cheque order lands on the thank-you page as * 'on-hold', where no conversion fires on either half, and is marked * 'processing' days later from wp-admin. That later request belongs to the * shop owner, not to the customer: their browser is somewhere else and no * cookie this request writes can reach it. So an admin request only prints * a browser half when it is demonstrably the customer's own -- an * admin-ajax call from the storefront carrying the session id the order was * stamped with -- and otherwise says so by returning false, which * on_purchase() records on the queue row as browser_half: skipped. * * @param mixed $order WC_Order. * @param int $order_id Order id. * @param string $currency Contract currency. * @return bool Whether a browser half was produced for this order. */ protected static function mirror_purchase( $order, $order_id, $currency ) { if ( ! class_exists( 'AdvisorPPC_Tags' ) || wp_doing_cron() ) { return false; } if ( is_admin() && ! self::request_is_the_customers( $order ) ) { return false; } if ( isset( self::$fired[ 'purchase_browser_' . $order_id ] ) ) { return true; } self::$fired[ 'purchase_browser_' . $order_id ] = true; $order_ref = self::order_ref( $order_id ); AdvisorPPC_Tags::enqueue_browser_event( 'purchase', array( 'value' => (float) $order->get_total(), 'currency' => $currency, 'order_ref' => $order_ref, ), AdvisorPPC_Events::event_id( 'purchase', $order_ref ) ); return true; } /** * Whether this request is being made by the customer who placed the order. * * The test is the click-ID session the order was stamped with at checkout * against the session this request carries. Only that proves the browser * on the other end of this request is the one that should receive the * pixel; a wp-admin screen belongs to the shop owner. * * @param mixed $order WC_Order. * @return bool */ protected static function request_is_the_customers( $order ) { if ( ! is_object( $order ) || ! method_exists( $order, 'get_meta' ) ) { return false; } $stamped = (string) $order->get_meta( self::META_SESSION_ID ); if ( '' === $stamped || ! class_exists( 'AdvisorPPC_ClickIDs' ) ) { return false; } $current = (string) AdvisorPPC_ClickIDs::get_session_id(); return '' !== $current && hash_equals( $stamped, $current ); } /** * refund. * * @param int $order_id Parent order id. * @param int $refund_id Refund order id. * @return void */ public static function on_refund( $order_id = 0, $refund_id = 0 ) { $order_id = (int) $order_id; $refund_id = (int) $refund_id; if ( $order_id <= 0 || $refund_id <= 0 || ! self::ready() || ! function_exists( 'wc_get_order' ) ) { return; } $order = wc_get_order( $order_id ); $refund = wc_get_order( $refund_id ); if ( ! $order || ! $refund || ! method_exists( $refund, 'get_amount' ) ) { return; } $sent_key = self::META_REFUND_SENT . '_' . $refund_id; if ( $order->get_meta( $sent_key ) ) { return; } $amount = abs( (float) $refund->get_amount() ); if ( $amount <= 0 ) { return; } $currency = AdvisorPPC_Events::currency( $order->get_currency() ); $recorded = AdvisorPPC_Events::record( 'refund', array( // The browser never emits a refund, so this reference is free to // carry the refund id. Without it two partial refunds on one // order would collapse into a single dedup key and the second // would be silently dropped. 'ref' => self::order_ref( $order_id ) . '-refund-' . $refund_id, 'order_ref' => self::order_ref( $order_id ), 'value' => $amount, 'currency' => $currency, 'occurred_at' => time(), 'session_id' => self::order_session_id( $order ), 'click_ids' => self::order_click_ids( $order ), 'user_data' => self::order_user_data( $order ), 'page' => array( 'url' => '', 'referrer' => '', 'title' => '', ), ) ); if ( $recorded ) { $order->update_meta_data( $sent_key, time() ); $order->save(); } } /** * Raw billing PII from an order. Hashed downstream, never transmitted raw. * * @param mixed $order WC_Order. * @return array */ private static function order_user_data( $order ) { if ( ! is_object( $order ) || ! method_exists( $order, 'get_billing_email' ) ) { return array(); } return array( 'em' => $order->get_billing_email(), 'ph' => $order->get_billing_phone(), 'fn' => $order->get_billing_first_name(), 'ln' => $order->get_billing_last_name(), 'ct' => $order->get_billing_city(), 'st' => $order->get_billing_state(), 'zp' => $order->get_billing_postcode(), 'country' => $order->get_billing_country(), ); } /** * Raw PII for the logged-in visitor, when there is one. * * @return array */ private static function current_user_data() { if ( ! is_user_logged_in() ) { return array(); } $user = wp_get_current_user(); if ( ! $user || empty( $user->ID ) ) { return array(); } return array( 'em' => $user->user_email, 'fn' => $user->first_name, 'ln' => $user->last_name, ); } /** * Click IDs stored on an order. * * @param mixed $order WC_Order. * @return array */ private static function order_click_ids( $order ) { if ( ! is_object( $order ) || ! method_exists( $order, 'get_meta' ) ) { return array(); } $stored = $order->get_meta( self::META_CLICK_IDS ); if ( is_array( $stored ) ) { return $stored; } if ( ! is_string( $stored ) || '' === $stored ) { return array(); } $decoded = json_decode( $stored, true ); return is_array( $decoded ) ? $decoded : array(); } /** * Session id stored on an order, falling back to the current visitor. * * @param mixed $order WC_Order. * @return string */ private static function order_session_id( $order ) { $stored = ''; if ( is_object( $order ) && method_exists( $order, 'get_meta' ) ) { $stored = (string) $order->get_meta( self::META_SESSION_ID ); } if ( '' !== $stored ) { return $stored; } return class_exists( 'AdvisorPPC_Events' ) ? AdvisorPPC_Events::session_id() : ''; } /** * Order creation time as unix seconds. * * @param mixed $order WC_Order. * @return int */ private static function order_timestamp( $order ) { if ( is_object( $order ) && method_exists( $order, 'get_date_created' ) ) { $created = $order->get_date_created(); if ( $created && method_exists( $created, 'getTimestamp' ) ) { return (int) $created->getTimestamp(); } } return time(); } } add_action( 'advisorppc_loaded', array( 'AdvisorPPC_Woo', 'init' ) );