ent used to say "no new per-page request is added", which was // true before that enqueue existed and is the sort of promise worth correcting // rather than leaving for someone to trust. // // The content-driven events below (view_item / view_cart / begin_checkout / // purchase) are URL-scoped or fire only on cache-excluded pages (the custom // order-received page too, see // WooCommerceModule::prevent_caching_custom_received_page()), so they stay // server-side. $cache_safe = (bool) $this->options->get( GTM4WP_OPTION_CACHE_SAFE_DATALAYER ); if ( ! $cache_safe ) { $data_layer = $this->add_customer_data( $data_layer, $woo ); $data_layer = $this->add_cart_content( $data_layer, $woo ); } // Product detail view data layer content. // // The order of the cart/checkout arms matters, because WooCommerce answers // both is_cart() and is_checkout() with true on some stores: a plugin or a // theme can define WOOCOMMERCE_CART or answer the woocommerce_is_cart // filter while the checkout page renders, and a leftover cart shortcode in // the checkout page content does the same. The checkout arm is therefore // tested first, matching WooCommerceModule::block_cart_or_checkout_context(), // which resolves the tracker's context the same way. When the two disagreed, // such a page emitted view_cart from here while the block tracker fired the // checkout steps, and begin_checkout could never fire at all. The // order-received endpoint keeps its place ahead of both: is_checkout() is // true there as well, and that page reports a purchase rather than a // checkout start. if ( is_product() ) { $data_layer = $this->add_product_view( $data_layer ); } elseif ( is_order_received_page() ) { $data_layer = $this->add_order_received_data( $data_layer ); } elseif ( is_checkout() ) { $this->add_begin_checkout( $woo ); } elseif ( is_cart() ) { $this->add_cart_view( $woo ); } // The one-shot cookie/session events are visitor/session specific, so they // are also withheld from cacheable HTML under the cache-safe data layer. if ( ! $cache_safe ) { $this->maybe_add_readded_to_cart( $woo ); // Reliable purchase tracking: if the order-received page was missed (custom // thank-you page, order-pay landing, a gateway that never reached it), emit // the purchase for the order remembered in this session on whatever page the // customer views next. No-op unless the feature is enabled. $data_layer = $this->maybe_add_pending_purchase( $data_layer ); } $this->datalayer->flush_pushes(); return apply_filters( GTM4WP_WPFILTER_EEC_DATALAYER_PAGELOAD, $data_layer ); } /** * Adds the logged-in customer's account, billing and shipping details to * the data layer when the customer-data feature is enabled. Present on * every page view. A fresh WC_Customer is loaded from the id so the order * count and total spent come from the database, not the session. * * @param array $data_layer The data layer collected so far. * @param mixed $woo The WooCommerce store object (WC()). * @return array */ private function add_customer_data( array $data_layer, $woo ): array { if ( ! $this->options->get( GTM4WP_OPTION_INTEGRATE_WCCUSTOMERDATA ) ) { return $data_layer; } if ( ! ( $woo->customer instanceof \WC_Customer ) ) { return $data_layer; } $woo_customer = new \WC_Customer( $woo->customer->get_id() ); $data_layer['customerTotalOrders'] = $woo_customer->get_order_count(); // get_total_spent() returns a wc_format_decimal() STRING; typed here so it // keeps reaching GTM as a JSON number now that the data layer encode no // longer numeric-coerces (JSON_NUMERIC_CHECK removed). $data_layer['customerTotalOrderValue'] = (float) $woo_customer->get_total_spent(); $data_layer['customerFirstName'] = $woo_customer->get_first_name(); $data_layer['customerLastName'] = $woo_customer->get_last_name(); $data_layer['customerBillingFirstName'] = $woo_customer->get_billing_first_name(); $data_layer['customerBillingLastName'] = $woo_customer->get_billing_last_name(); $data_layer['customerBillingCompany'] = $woo_customer->get_billing_company(); $data_layer['customerBillingAddress1'] = $woo_customer->get_billing_address_1(); $data_layer['customerBillingAddress2'] = $woo_customer->get_billing_address_2(); $data_layer['customerBillingCity'] = $woo_customer->get_billing_city(); $data_layer['customerBillingState'] = $woo_customer->get_billing_state(); $data_layer['customerBillingPostcode'] = $woo_customer->get_billing_postcode(); $data_layer['customerBillingCountry'] = $woo_customer->get_billing_country(); $data_layer['customerBillingEmail'] = $woo_customer->get_billing_email(); $data_layer['customerBillingEmailHash'] = Helpers::normalize_and_hash_email_address( 'sha256', $woo_customer->get_billing_email() ); $data_layer['customerBillingPhone'] = $woo_customer->get_billing_phone(); $data_layer['customerShippingFirstName'] = $woo_customer->get_shipping_first_name(); $data_layer['customerShippingLastName'] = $woo_customer->get_shipping_last_name(); $data_layer['customerShippingCompany'] = $woo_customer->get_shipping_company(); $data_layer['customerShippingAddress1'] = $woo_customer->get_shipping_address_1(); $data_layer['customerShippingAddress2'] = $woo_customer->get_shipping_address_2(); $data_layer['customerShippingCity'] = $woo_customer->get_shipping_city(); $data_layer['customerShippingState'] = $woo_customer->get_shipping_state(); $data_layer['customerShippingPostcode'] = $woo_customer->get_shipping_postcode(); $data_layer['customerShippingCountry'] = $woo_customer->get_shipping_country(); return $data_layer; } /** * Builds the process_product() attributes for a cart line, adding the display * price derived from WooCommerce's already-calculated line totals so * process_product() does not recompute wc_get_price_to_display() for every cart * item - the cause of the reported cart/checkout memory exhaustion (#436). The * price is omitted (and process_product() computes it) when the line totals are * not yet available. * * @param array $cart_item_data The WooCommerce cart item. * @return array */ private function cart_line_attributes( array $cart_item_data ): array { $attributes = array( 'quantity' => $cart_item_data['quantity'], ); $include_tax = ( 'incl' === get_option( 'woocommerce_tax_display_shop' ) ); $price = Helpers::cart_line_display_price( $cart_item_data, $include_tax ); if ( null !== $price ) { $attributes['price'] = $price; } // GA4 per-item discount, added only when a coupon/sale actually reduced the // line (#348); omitted otherwise so undiscounted items carry no discount key. $discount = Helpers::cart_line_discount( $cart_item_data, $include_tax ); if ( null !== $discount ) { $attributes['discount'] = $discount; } return $attributes; } /** * Adds the current cart content (totals + visible items) to the data layer * when the cart-content feature is enabled. Present on every page view. * * @param array $data_layer The data layer collected so far. * @param mixed $woo The WooCommerce store object (WC()). * @return array */ private function add_cart_content( array $data_layer, $woo ): array { if ( ! $this->options->get( GTM4WP_OPTION_INTEGRATE_WCEINCLUDECARTINDL ) || ! isset( $woo ) || ! isset( $woo->cart ) ) { return $data_layer; } $current_cart = $woo->cart; // The money totals are cast to float: the WC_Cart getters pass through // woocommerce_cart_* filters that third-party code may answer with decimal // strings, and the data layer encode no longer numeric-coerces // (JSON_NUMERIC_CHECK removed), so the totals are typed here to stay real // JSON numbers. Coupon codes are identifiers and stay strings. $data_layer['cartContent'] = array( 'totals' => array( 'applied_coupons' => $current_cart->get_applied_coupons(), 'discount_total' => (float) $current_cart->get_discount_total(), 'subtotal' => (float) $current_cart->get_subtotal(), 'total' => (float) $current_cart->get_cart_contents_total(), ), 'items' => array(), ); foreach ( $current_cart->get_cart() as $cart_item_id => $cart_item_data ) { /** * Applying WooCommerce's own woocommerce_cart_item_product filter here is essential in order to hide everything * from tracking codes that is not visible to the user as well. */ $product = apply_filters( 'woocommerce_cart_item_product', $cart_item_data['data'], $cart_item_data, $cart_item_id ); /** * This filter allows 3rd party code to exclude specific products from reporting. * * @param bool true Constant value telling 3rd party code that the order item will be included in reporting if not changed by the filter. * @param array $cart_item_data Associative array generated by WooCommerce returned by the WC()->cart->get_cart() function call. * * return bool If the filter returns false, the cart item will be omitted from processing. */ if ( ! apply_filters( GTM4WP_WPFILTER_EEC_CART_ITEM, true, $cart_item_data ) || ! apply_filters( 'woocommerce_widget_cart_item_visible', true, $cart_item_data, $cart_item_id ) ) { continue; } $eec_product_array = $this->product_data->process_product( $product, $this->cart_line_attributes( $cart_item_data ), 'cart', $cart_item_data ); unset( $eec_product_array['internal_id'] ); $data_layer['cartContent']['items'][] = $eec_product_array; } return $data_layer; } /** * Builds the product-detail (view_item) data layer content and fires the * view_item event for simple products and, when enabled, variable products * on the parent. No-op unless e-commerce tracking is enabled. * * @param array $data_layer The data layer collected so far. * @return array */ private function add_product_view( array $data_layer ): array { if ( ! $this->options->get( GTM4WP_OPTION_INTEGRATE_WCTRACKECOMMERCE ) ) { return $data_layer; } $postid = get_the_ID(); $product = wc_get_product( $postid ); if ( ! $product instanceof \WC_Product ) { return $data_layer; } // GA4 expects a quantity on the view_item item; it defaults to 1 for a single // product view (#348). Making it explicit keeps the payload spec-complete. $eec_product_array = $this->product_data->process_product( $product, array( 'quantity' => 1 ), 'productdetail' ); $data_layer['productRatingCounts'] = $product->get_rating_counts(); $data_layer['productAverageRating'] = (float) $product->get_average_rating(); $data_layer['productReviewCount'] = (int) $product->get_review_count(); $data_layer['productType'] = $product->get_type(); // GA4 list attribution (#405): a product page is full-page cacheable, so the // list the visitor came from must never be baked into this HTML server-side. // Instead the push is wrapped in a JS call that merges it from the first-party // cookie in the browser - the payload below stays identical for every visitor. // The cookie is keyed by the list item's product id, which is what internal_id // carries here (the same value the client-side variation path looks up). $list_product_id = $eec_product_array['internal_id'] ?? $postid; $list_wrapper = ''; $list_wrapper_args = array(); if ( true === $this->options->get( GTM4WP_OPTION_INTEGRATE_WCLISTATTRIBUTION ) ) { $list_wrapper = Helpers::LIST_ATTRIBUTION_JS_WRAPPER; $list_wrapper_args = array( $list_product_id ); } switch ( $data_layer['productType'] ) { case 'variable': $data_layer['productIsVariable'] = 1; if ( true === $this->options->get( GTM4WP_OPTION_INTEGRATE_WCVIEWITEMONPARENT ) ) { $gtm4wp_currency = get_woocommerce_currency(); unset( $eec_product_array['internal_id'] ); $this->datalayer->queue_push( 'view_item', array( 'ecommerce' => array( 'currency' => $gtm4wp_currency, 'value' => $eec_product_array['price'], 'items' => array( $eec_product_array, ), ), ), $this->clear_ecommerce_js(), '', $list_wrapper, $list_wrapper_args ); } break; case 'grouped': $data_layer['productIsVariable'] = 0; break; default: $data_layer['productIsVariable'] = 0; $gtm4wp_currency = get_woocommerce_currency(); unset( $eec_product_array['internal_id'] ); $this->datalayer->queue_push( 'view_item', array( 'ecommerce' => array( 'currency' => $gtm4wp_currency, 'value' => $eec_product_array['price'], 'items' => array( $eec_product_array, ), ), ), $this->clear_ecommerce_js(), '', $list_wrapper, $list_wrapper_args ); } return $data_layer; } /** * Fires the GA4 view_cart event for the current cart. No-op unless * e-commerce tracking is enabled or the cart is empty. * * @param mixed $woo The WooCommerce store object (WC()). * @return void */ private function add_cart_view( $woo ): void { if ( ! $this->options->get( GTM4WP_OPTION_INTEGRATE_WCTRACKECOMMERCE ) ) { return; } $gtm4wp_cart_products = array(); $gtm4wp_cart_total = 0; $gtm4wp_currency = get_woocommerce_currency(); foreach ( $woo->cart->get_cart() as $cart_item_id => $cart_item_data ) { /** * Applying WooCommerce's own woocommerce_cart_item_product filter here is essential in order to hide everything * from tracking codes that is not visible to the user as well. */ $product = apply_filters( 'woocommerce_cart_item_product', $cart_item_data['data'], $cart_item_data, $cart_item_id ); if ( ! apply_filters( GTM4WP_WPFILTER_EEC_CART_ITEM, true, $cart_item_data ) ) { continue; } $eec_product_array = $this->product_data->process_product( $product, $this->cart_line_attributes( $cart_item_data ), 'cart', $cart_item_data ); unset( $eec_product_array['internal_id'] ); $gtm4wp_cart_products[] = $eec_product_array; $gtm4wp_cart_total += $eec_product_array['price'] * $eec_product_array['quantity']; } // Do not fire GTM event if no products are in the cart. if ( count( $gtm4wp_cart_products ) > 0 ) { $this->datalayer->queue_push( 'view_cart', array( 'ecommerce' => array( 'currency' => $gtm4wp_currency, 'value' => $gtm4wp_cart_total, 'items' => $gtm4wp_cart_products, ), ), $this->clear_ecommerce_js() ); } } /** * Fires an add_to_cart event when a product was just re-added to the cart * after being removed (the "Undo" link on the cart page). The pending * re-add is flagged in the WooCommerce session by cart_item_restored(). * * @param mixed $woo The WooCommerce store object (WC()). * @return void */ private function maybe_add_readded_to_cart( $woo ): void { if ( ! $woo || ! $woo->session ) { return; } $cart_readded_hash = $woo->session->get( 'gtm4wp_product_readded_to_cart' ); if ( ! isset( $cart_readded_hash ) ) { return; } $cart_item = $woo->cart->get_cart_item( $cart_readded_hash ); if ( ! empty( $cart_item ) ) { $product = $cart_item['data']; $eec_product_array = $this->product_data->process_product( $product, $this->cart_line_attributes( $cart_item ), 'readdedtocart', $cart_item ); $gtm4wp_currency = get_woocommerce_currency(); unset( $eec_product_array['internal_id'] ); $this->datalayer->queue_push( 'add_to_cart', array( 'ecommerce' => array( 'currency' => $gtm4wp_currency, 'value' => $eec_product_array['price'] * $eec_product_array['quantity'], 'items' => array( $eec_product_array ), ), ), $this->clear_ecommerce_js() ); } $woo->session->set( 'gtm4wp_product_readded_to_cart', null ); } /** * Fires the GA4 begin_checkout event for the current cart and exposes the * cart products to the checkout tracker as an inline script. No-op unless * e-commerce tracking is enabled. * * @param mixed $woo The WooCommerce store object (WC()). * @return void */ private function add_begin_checkout( $woo ): void { if ( ! $this->options->get( GTM4WP_OPTION_INTEGRATE_WCTRACKECOMMERCE ) ) { return; } $gtm4wp_checkout_products = array(); $gtm4wp_checkout_total = 0; $gtm4wp_currency = get_woocommerce_currency(); foreach ( $woo->cart->get_cart() as $cart_item_id => $cart_item_data ) { /** * Applying WooCommerce's own woocommerce_cart_item_product filter here is essential in order to hide everything * from tracking codes that is not visible to the user as well. */ $product = apply_filters( 'woocommerce_cart_item_product', $cart_item_data['data'], $cart_item_data, $cart_item_id ); if ( ! apply_filters( GTM4WP_WPFILTER_EEC_CART_ITEM, true, $cart_item_data ) ) { continue; } $eec_product_array = $this->product_data->process_product( $product, $this->cart_line_attributes( $cart_item_data ), 'checkout', $cart_item_data ); unset( $eec_product_array['internal_id'] ); $gtm4wp_checkout_products[] = $eec_product_array; $gtm4wp_checkout_total += $eec_product_array['quantity'] * $eec_product_array['price']; } // end foreach cart item // Do not fire GTM event if no products are in the cart. if ( count( $gtm4wp_checkout_products ) > 0 ) { $this->datalayer->queue_push( 'begin_checkout', array( 'ecommerce' => array( 'currency' => $gtm4wp_currency, 'value' => $gtm4wp_checkout_total, 'items' => $gtm4wp_checkout_products, ), ), $this->clear_ecommerce_js() ); } $checkout_js = ' window.gtm4wp_checkout_products = ' . ScriptTag::json_literal( $gtm4wp_checkout_products, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS ) . '; window.gtm4wp_checkout_value = ' . ScriptTag::number_literal( (float) $gtm4wp_checkout_total ) . ';'; /* * Nothing on the page reads these globals unless the classic tracker is on * it, so "the handle is not there" is not a missed attach - it means there * is no reader. That is the ordinary state of a BLOCK-based checkout: * WooCommerceModule::enqueue_scripts() deliberately loads * gtm4wp-woocommerce-blocks INSTEAD of gtm4wp-woocommerce there (the block * tracker reads the wc/store data registry, never these globals) while * is_checkout() is still true here, so this method still runs. Emitting the * fallback in that case would print a payload with no reader, duplicating * the items already queued into the begin_checkout push above. * * 'enqueued' rather than 'registered': a handle another plugin has dequeued * stays registered but is never printed, so it has no reader either. It * stays true after the script is printed (WP_Dependencies::do_items() clears * to_do, not queue), so the already-done case below is still reached. */ if ( ! wp_script_is( 'gtm4wp-woocommerce', 'enqueued' ) ) { return; } /* * Replaces the deprecated wc_enqueue_js() (WooCommerce 10.4, PA-8). The two * window.* assignments never needed jQuery; 'before' placement emits them * just ahead of the gtm4wp-woocommerce tracker that reads them. * * An inline script can only be attached while its handle is still pending. * This runs on the data layer compile filter fired from wp_head priority 10, * after wp_print_head_scripts() (priority 9), so on a site that filters the * tracker into the instead of the footer the handle is already done * and the attach would silently drop the checkout data - leaving * add_shipping_info / add_payment_info to report an empty item list and a * value of 0. Print the block ourselves in the footer in that case; it is * the same placement the WooCommerce queue used to give us, without the * deprecated call. * * The return value of wp_add_inline_script() is still honoured as a second * leg. With the enqueued check above, core can only return false here for * an empty payload, which this one never is - but the documented contract * of the function is what this depends on, not the internals of * WP_Scripts::add_data(). */ if ( wp_script_is( 'gtm4wp-woocommerce', 'done' ) || ! wp_add_inline_script( 'gtm4wp-woocommerce', $checkout_js, 'before' ) ) { if ( '' === $this->deferred_checkout_js ) { add_action( 'wp_footer', array( $this, 'print_deferred_checkout_js' ), 5 ); } $this->deferred_checkout_js = $checkout_js; } } /** * Prints the checkout globals that could not be attached to the tracker * handle, as a standalone inline script block in the footer. Registered on * wp_footer by add_begin_checkout() only in that fallback case; a no-op * otherwise. * * @return void */ public function print_deferred_checkout_js(): void { if ( '' === $this->deferred_checkout_js ) { return; } $block = "\n" . $this->script_tag->opening_tag() . $this->deferred_checkout_js . "\n"; // Cleared before printing so a second wp_footer pass cannot repeat the block. $this->deferred_checkout_js = ''; $this->script_tag->print_script_block( $block ); } /** * Builds the order-received (thankyou) page data layer: the raw order data * plus the GA4 purchase event, queued together with the browser-side * duplicate-tracking guard. The order is only exposed when its key matches * the request, it is within the tracking age and it has not been tracked * yet (see ProductData::is_purchase_already_tracked()); the customer identity * blocks additionally need this visitor to be one WooCommerce would show the * order to (see woocommerce_hides_order_from_visitor()). * * @param array $data_layer The data layer collected so far. * @return array */ private function add_order_received_data( array $data_layer ): array { global $wp; // Suppressing 'Processing form data without nonce verification.' message as there is no nonce accessible in this case. $order_id = filter_var( wp_unslash( isset( $_GET['order'] ) ? $_GET['order'] : '' ), FILTER_VALIDATE_INT ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! $order_id && isset( $wp->query_vars['order-received'] ) ) { $order_id = $wp->query_vars['order-received']; } $order_id = absint( $order_id ); $order_id_filtered = apply_filters( 'woocommerce_thankyou_order_id', $order_id ); if ( '' !== $order_id_filtered ) { $order_id = $order_id_filtered; } // Suppressing 'Processing form data without nonce verification.' message as there is no nonce accessible in this case. $order_key = isset( $_GET['key'] ) ? wc_clean( sanitize_text_field( wp_unslash( $_GET['key'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_key = apply_filters( 'woocommerce_thankyou_order_key', $order_key ); $order = null; if ( $order_id > 0 ) { $order = wc_get_order( $order_id ); if ( $order instanceof \WC_Order ) { // hash_equals(), not !==: this compares a secret the request supplies // against the stored one, and it guards the very same URL as the check // in WC_Shortcode_Checkout::order_received(), which has always used // hash_equals(). Against a long random key over HTTP the timing channel // is not a practical attack; the two checks simply should not differ in // kind. Both sides are cast because the value reaching the comparison // has passed through a public filter (see above) and hash_equals() // throws on a non-string. if ( ! hash_equals( (string) $order->get_order_key(), (string) $order_key ) ) { $order = null; } } else { $order = null; } } // Whether the order was resolved from the REQUEST - the ?order= id plus a // matching ?key=. Anyone holding that URL is "the request", which is what // woocommerce_hides_order_from_visitor() below reasons about; the session // fallback that follows resolves the buyer's own order instead, so it is // deliberately exempt. $from_request = $order instanceof \WC_Order; // Custom order-received page: a bespoke thank-you page (selected in the // "Custom order received page" option) carries no order id or key in its // URL, so resolve the order from this browser's session instead. The // session belongs to the buyer, so no order-key check is possible or needed. if ( ! ( $order instanceof \WC_Order ) ) { $session_order_id = $this->pending_session_order_id(); if ( $session_order_id > 0 ) { $session_order = wc_get_order( $session_order_id ); if ( $session_order instanceof \WC_Order ) { $order = $session_order; $order_id = $session_order_id; } } } /** * From this point if for any reason purchase data is not pushed * that is because for a specific reason. * In any other case the woocommerce_thankyou hook will be the fallback if * is_order_received_page does not work. */ $GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] = true; if ( ! ( $order instanceof \WC_Order ) ) { return $data_layer; } // A forwarded or otherwise leaked order-received URL still carries a valid // order key, so the checks above cannot tell it apart from the buyer's own // visit - but WooCommerce may already have decided not to show that visitor // the order (see woocommerce_hides_order_from_visitor()). The purchase event // keeps firing on the key alone, since the buyer arriving straight from // checkout is logged in or well inside the verification grace period and // would otherwise lose the conversion; only the customer identity blocks are // withheld, in exactly the cases WooCommerce itself would have declined to // render the order. $withhold_customer_data = $from_request && $this->woocommerce_hides_order_from_visitor( $order ); return $this->add_purchase_for_order( $data_layer, $order, (int) $order_id, $withhold_customer_data ); } /** * Whether WooCommerce itself would refuse to render this order to whoever is * making the current request, in which case the data layer withholds the * customer identity blocks (orderData.customer, new_customer / customer_type * and the purchase event's user_data) even though the purchase event still * fires. * * WooCommerce grew two gates in WC_Shortcode_Checkout::order_received() that * run AFTER the order-key check and return before woocommerce_thankyou, so * nothing on this side observes them; the decision is re-derived instead, and * the rule is upstream PARITY in both directions. Withholding more than * upstream is not safety: wherever order_received() renders, the page body is * already showing this visitor the order, so extra withholding only deletes * tracking data. Publishing more than upstream is the real failure. Where a * term of upstream's decision cannot be read from here, the mirror therefore * drops it in the withhold direction - a guarantee that holds for every * monotone callback on the filters below (a passthrough, a constant, or one * that only ANDs its own conditions onto the value it was handed); the one * shape it cannot cover is a strictly value-inverting callback on the * final-say filter, named as the fourth accepted residual at that call. * * The version surface, measured at the release tags rather than remembered * (registry rows: .upstream U113): * * - 5.0-7.8.x: NO gates. order_received() renders the full order to any * holder of a valid key link, so there is no upstream decision to mirror * and nothing is withheld. Decided by probing the symbol both gates * arrived with (guest_should_verify_email, 7.9.0, still present on * 11.0.0), so no version number is compared anywhere. * - 7.9.0+: a non-guest order requires being logged in as its customer. * Unconditional until 8.4.0, which wrapped it in the * woocommerce_order_received_verify_known_shoppers filter read below. * - 7.9.0+: a guest order requires billing-email verification, decided by * guest_should_verify_email() in the shortcode through 8.5.x - 7.9.x has * no grace period; the 10-minute filterable grace arrived in 8.0.0 - and * by Users::should_user_verify_order_email() from 8.6.0, which is asked * directly when it exists. That helper lives in an Internal namespace * with no compatibility promise (UC-2), hence the guard around the * delegation. * * Between the two (7.9.0-8.5.x, or any future WooCommerce that moves the * helper while keeping the gates) the fallback mirrors the shortcode's own * guest_should_verify_email() term by term, for every order shape that * reaches it - upstream applies it to a known shopper's order too once the * site filters the login gate off. Three terms identify the REQUEST rather * than the order and are deliberately not modelled: the WooCommerce session * email match, the POSTed-email escape hatch (it needs upstream's own nonce * and field names), and read_private_shop_orders. Each omission makes the * mirror withhold where upstream renders, never the reverse - for any * monotone callback on the final-say filter; only a strictly value-inverting * callback there can compose with these omissions into publishing where * upstream hides (see that filter's comment). The purchase event is * unaffected either way. * * @param \WC_Order $order The order resolved from the request. * @return bool True when the order data must not be attributed to this visitor. */ private function woocommerce_hides_order_from_visitor( \WC_Order $order ): bool { // Feature-detect the release that introduced both gates: absent means a // WooCommerce (5.0-7.8.x, measured at the 7.8.0 and 7.9.0 tags) that // renders the order to any holder of a valid key link, so publishing // matches upstream exactly. The probe is accurate even before the class // is loaded - method_exists() triggers WC_Autoloader, which has mapped // the wc_shortcode_ prefix to includes/shortcodes/ since before 5.0, and // it sees the member through its private visibility (probed). Residual: // were the class ever unloadable while WooCommerce still served this // page, this publishes - accepted, because that site's order-received // page fatals on its own before this code matters, and U113's Release // Radar watches the symbol so an upstream rename is caught at RC time. if ( ! method_exists( 'WC_Shortcode_Checkout', 'guest_should_verify_email' ) ) { return false; } /** * Indicates if known (non-guest) shoppers need to be logged in before we let * them access the order received page. Documented and applied by WooCommerce * itself; read here so the data layer follows the same decision. * * The gate is older than its filter: order_received() requires the login * unconditionally from 7.9.0 and 8.4.0 only made it filterable. A site * callback returning false is honoured here on 7.9.0-8.3.x too, where * upstream ignores it - the callback is the admin asking for the gate to * be off, and the mirror follows the admin. * * @since WooCommerce 8.4.0 (the login requirement itself: 7.9.0) * * @param bool $verify_known_shoppers If verification is required. */ $verify_known_shoppers = (bool) apply_filters( 'woocommerce_order_received_verify_known_shoppers', true ); $order_customer_id = (int) $order->get_customer_id(); if ( $verify_known_shoppers && $order_customer_id > 0 && get_current_user_id() !== $order_customer_id ) { return true; } $users_class = 'Automattic\WooCommerce\Internal\Utilities\Users'; if ( class_exists( $users_class ) && method_exists( $users_class, 'should_user_verify_order_email' ) ) { // WooCommerce 8.6.0+: ask the decision's current owner directly. The // supplied email stays null because the POSTed-email escape hatch is // deliberately not reproduced (see the method docblock); a null can // only make the helper withhold more, never less. return (bool) $users_class::should_user_verify_order_email( $order->get_id(), null, 'order-received' ); } // 7.9.0-8.5.x: the decision still lives inline in the shortcode, so what // follows mirrors guest_should_verify_email() as it shipped there (read // at the 7.9.0, 8.0.0 and 8.5.2 tags), term by term, for every order // shape that reaches this point - upstream runs it for a known shopper's // order too when the login gate above is filtered off, and its only // customer-id term is the owner short-circuit below. // Upstream renders an order with no billing email on every gated version: // there is nothing to verify a visitor against (an admin-created phone // order is the common shape). if ( empty( $order->get_billing_email() ) ) { return false; } // Upstream renders for the logged-in owner. With the known-shopper gate // on, this is the only way a customer order reaches here; with the gate // filtered off, it keeps the buyer reading their own order exempt no // matter what the filter said. if ( $order_customer_id > 0 && get_current_user_id() === $order_customer_id ) { return false; } /** * Documented and applied by WooCommerce itself; read here so the * fallback uses the site's own grace period rather than a second * hardcoded one. * * All three arguments are passed because WooCommerce passes three, in * both homes this filter has had - the shortcode on 8.0.0-8.5.x and * Users::should_user_verify_order_email() from 8.6.0. WP_Hook hands a * callback exactly the list the caller supplied and never pads it, so * a site callback written to the documented three-parameter signature * raises an uncaught ArgumentCountError if we pass fewer. Passing all * three is safe in the other direction: WP_Hook slices the list down * to each callback's own accepted_args. * * On 7.9.x itself neither this filter nor any grace period exists, so * within these ten minutes the mirror publishes where 7.9.x would * already demand verification - the one window where it is laxer than * upstream. Bounded by the order max-age gate, and it is the exact * behaviour 8.0.0 (the next release, same summer 2023) adopted. * * @since WooCommerce 8.0.0 * * @param int $grace_period Seconds an order stays viewable without verification. * @param \WC_Order $order The order whose visibility is being decided. * @param string $context The context the check runs in. */ $grace_period = (int) apply_filters( 'woocommerce_order_email_verification_grace_period', 10 * MINUTE_IN_SECONDS, $order, 'order-received' ); $created = $order->get_date_created(); // <= rather than <: upstream's own comparison, so the boundary second // matches too. No creation date skips this short-circuit exactly like // upstream's is_a() check does, and the decision falls to the filter // below - which withholds unless the site opted out. if ( $created && ( time() - $created->getTimestamp() ) <= $grace_period ) { return false; } /** * The final say upstream gives a site over the verification requirement, * and its documented opt-out ("the filter primarily exists as a way to * *remove* the email verification step") - honoured so a store that * disabled verification keeps a complete data layer. Three arguments for * the same WP_Hook reason as above (RI-25). Because the request-identity * terms are not modelled, the value passed in is true in cases where * upstream would have computed false - so a passthrough callback changes * nothing, and only an explicit false publishes. That approximation is * fail-closed for every monotone callback; the residual is a strictly * value-inverting callback (false on true, true on false), which would * publish here in exactly the request-identity states upstream computed * false for - states that identify the visitor as the buyer or a * read_private_shop_orders holder. Accepted as the fourth residual, * alongside the probe, known-shoppers and 7.9.x-grace laxities above. * Upstream applies it after the grace short-circuit, never inside it, * which is why it is not consulted for a fresh order above. * * @since WooCommerce 7.9.0 * * @param bool $email_verification_required Whether the visitor must verify the billing email first. * @param \WC_Order $order The order whose visibility is being decided. * @param string $context The context the check runs in. */ return (bool) apply_filters( 'woocommerce_order_email_verification_required', true, $order, 'order-received' ); } /** * Reliable purchase tracking fallback. When the "purchase on any page" option * is on and the order-received page did not already fire the purchase this * request, emit the purchase for the order remembered in this browser's * session (seeded by PurchaseTracking::remember_order() at payment/status * time, or by an order-received render for an order that got there before * its status became trackable). This fires on whatever page the customer * views next - so a customized thank-you page, landing on the order-pay page, * or a Stripe order still "Pending payment" when the customer arrived on the * order-received page, no longer loses the sale. The order-tracked flag, age * gate and browser cookie prevent double counting. * * The marker is consumed on every outcome but one: an order that is not * trackable YET (ProductData::may_become_trackable()) is left in the session, * untouched and with nothing emitted - not even orderData, which a GTM setup * commonly uses as its "this is the confirmation" signal - so the next page * view asks again. That wait is bounded: the order's status turning terminal, * the order aging past the re-check window or the "Maximum order age", or the * order being flagged tracked elsewhere all consume it on the next check. * * @param array $data_layer The data layer collected so far. * @return array */ private function maybe_add_pending_purchase( array $data_layer ): array { if ( true !== $this->options->get( GTM4WP_OPTION_INTEGRATE_WCPURCHASEONANYPAGE ) ) { return $data_layer; } if ( ! empty( $GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] ) ) { return $data_layer; } $order_id = $this->pending_session_order_id(); if ( $order_id <= 0 ) { return $data_layer; } $order = wc_get_order( $order_id ); // Still waiting for the status to catch up: keep the marker, emit nothing, // and leave the request-scoped flag down as well - nothing was pushed, and // a status change landing later in this very request (a gateway callback // rendered as a page) must still be able to seed through remember_order(). if ( $order instanceof \WC_Order && $this->product_data->may_become_trackable( $order, $order_id ) ) { return $data_layer; } // Every other outcome consumes the pending marker - the purchase is emitted // below, or the order is gone, already tracked, terminal or too old - so the // fallback does not re-evaluate the same order on every subsequent page view. $this->clear_pending_session_order(); $GLOBALS['gtm4wp_woocommerce_purchase_data_pushed'] = true; if ( ! ( $order instanceof \WC_Order ) ) { return $data_layer; } return $this->add_purchase_for_order( $data_layer, $order, $order_id ); } /** * Runs the purchase eligibility gauntlet on a resolved order and, when it * passes, adds the raw order data, queues the GA4 purchase event wrapped in the * browser-side duplicate guard and flags the order as tracked. Shared by the * standard order-received page and the session fallback so both apply the same * age / already-tracked / trackable-status rules and produce identical output. * * @param array $data_layer The data layer collected so far. * @param \WC_Order $order The resolved order. * @param int $order_id The order id (for the cookie dedupe check). * @param bool $withhold_customer_data Whether to leave the customer identity * blocks out (see the caller). * @return array */ private function add_purchase_for_order( array $data_layer, \WC_Order $order, int $order_id, bool $withhold_customer_data = false ): array { if ( $this->product_data->is_order_older_than_max_age( $order ) ) { return $data_layer; } $order_items = null; // Raw order data will be output regardless of whether the purchase has been already tracked previously, since this data is not meant to track using GA. if ( $this->options->get( GTM4WP_OPTION_INTEGRATE_WCORDERDATA ) ) { $order_items = $this->product_data->process_order_items( $order ); $data_layer['orderData'] = $this->product_data->get_raw_order_datalayer( $order, $order_items ); // Dropped after the GTM4WP_WPFILTER_EEC_ORDER_DATA filter rather than // never built, so the filter keeps seeing the shape it has always been // handed. Be precise about what that ordering buys: this is the last // write to the 'customer' KEY, not to orderData as a whole, so a filter // that copies billing details onto a key of its own survives this unset. // The gate has the final say over the key it names and no say over // anything else a third party writes. // // The line drawn here is IDENTITY, not sensitivity. 'customer' holds the // names, addresses, email, phone and their hashes, and that is the whole // of what is withheld; everything else in orderData describes the ORDER // rather than the buyer, and this visitor is already being told about the // order by the purchase event that deliberately keeps firing. // // Do NOT extend this unset to 'attributes' or 'totals' on the theory that // the purchase event duplicates them. Measured, it does not: it carries // neither the creation date, the payment method, the payment method title, // the shipping method nor the status, and it omits six of the ten totals. // Extending it would also delete values this visitor demonstrably already // holds - the order key they supplied in the URL, and the order number the // duplicate guard prints beside this block - and orderData.attributes.order_number // is named in readme.txt as a variable containers read, so removing it // breaks them with no error. // // No is_array() guard: get_raw_order_datalayer() declares an array return, // so a filter handing back a scalar is a TypeError there, not here. if ( $withhold_customer_data ) { unset( $data_layer['orderData']['customer'] ); } } // The canonical eligibility gauntlet (age / already-tracked / status). // The separate age check above only exists so orderData is skipped for // too-old orders as well; the composite re-runs it for free. if ( ! $this->product_data->is_order_trackable( $order, $order_id ) ) { // Withheld on the status alone, with the order still able to reach a // tracked status (the customer arrived before the gateway's webhook moved // it out of "Pending payment"): remember it in the buyer's session so the // reliable-purchase fallback re-checks it on their next page views. The // order stays unflagged, so a revisit of this page fires as before, and // orderData above was still written for this render. No-op unless // "Reliable purchase tracking" is on, and for every other reason to // withhold (already tracked, terminal, too old). // // Not for a visitor WooCommerce itself would hide the order from: the // re-check emits from the SESSION, where the order is taken to be the // buyer's own and the customer identity blocks are not withheld, so // seeding here for a holder of a forwarded confirmation URL would hand // them, on their next page view, exactly the identity data this render // just kept from them. The buyer arriving from checkout is logged in or // inside the verification grace period and passes this gate. if ( ! $withhold_customer_data ) { $this->product_data->remember_if_may_become_trackable( $order, $order_id ); } return $data_layer; } // new_customer / customer_type are derived from the BUYER's order history, // which makes them a fact about the person rather than about the order - // the same side of the line 'customer' and user_data sit on - so they are // withheld with them. They are the only retained values that were on the // identity side of that line. // // Omitted entirely rather than emitted falsy: a consumer's GTM trigger may // test for key presence, so inventing a 'returning' would be a behaviour // change where an absent key is honest (RI-13's omit-don't-invent, and #121 // is the recorded case of emitting both keys with one meaningless). // // The two sibling call sites are deliberately NOT gated. resolve_pending_purchase() // resolves the order from the caller's own WC session, so the visitor is the // buyer; PurchaseTracking::on_thankyou() leaves the gate to the hook's caller // (WooCommerce's own callers gate first). if ( ! $withhold_customer_data ) { $data_layer = array_merge( $data_layer, $this->product_data->customer_signals( $order ) ); } $purchase_data_layer = $this->product_data->get_purchase_datalayer( $order, $order_items ); // The Enhanced Conversions block is the purchase event's own copy of the // customer identity - hashed email and phone, plus the plaintext address // Google expects - so it is withheld with orderData.customer, and for the // same reason. The event itself (transaction id, value, items) is untouched. if ( $withhold_customer_data ) { unset( $purchase_data_layer['user_data'] ); } // The browser-side duplicate guard records this order in the // gtm4wp_orderid_tracked cookie / localStorage. When the "Do not flag orders // as being tracked" option is on, the admin has asked the plugin not to // remember tracked orders anywhere, so the browser guard is skipped as well - // matching the server-side is_purchase_already_tracked() / flag_order_tracked() // short-circuits, which otherwise leave a stale localStorage flag behind (#369). if ( (bool) $this->options->get( GTM4WP_OPTION_INTEGRATE_WCNOORDERTRACKEDFLAG ) ) { $before_purchase_dl_push = ''; $after_purchase_dl_push = ''; } else { list( $before_purchase_dl_push, $after_purchase_dl_push ) = $this->purchase_dedupe_guard( $order ); } $this->datalayer->queue_push( $purchase_data_layer['event'], $purchase_data_layer, $before_purchase_dl_push . $this->clear_ecommerce_js(), $after_purchase_dl_push ); $this->product_data->flag_order_tracked( $order ); $this->clear_pending_session_order(); return $data_layer; } /** * Builds the browser-side duplicate-tracking guard wrapped around the purchase * push: a "before" fragment that only pushes when this order id is not already * recorded in the cookie / local storage, and an "after" fragment that records * it. Extracted so the order-received page and the session fallback share the * exact same guard. * * The cookie read/write idiom emitted below is the PHP-side copy of the * shared helpers in js/frontend/lib/gtm4wp-cookies.js (this inline script * cannot import a bundle module); the storage key and byte format must stay * compatible with that lib and with gtm4wp-visitor-data.js, which reuses the * same gtm4wp_orderid_tracked guard for the fallback purchase. * * @param \WC_Order $order The order being tracked. * @return array{0:string,1:string} The before and after JavaScript fragments. */ private function purchase_dedupe_guard( \WC_Order $order ): array { // Emitted as a JSON string literal (quotes included, so it is NOT wrapped // in quotes below) with the full hex flag set - the RI-2 escaper for an // inline-script context. It replaces an esc_js() call, which was wrong on // two counts: esc_js() is for HTML-attribute JS, not a raw