api = $api; $this->identity = $identity; $this->product_data = $product_data; // Primary hook for payment-based order completion. add_action( 'woocommerce_payment_complete', array( $this, 'track_order_completed' ), 10, 1 ); // Backup hook for COD, BACS, manual completions. add_action( 'woocommerce_order_status_completed', array( $this, 'track_order_completed' ), 10, 1 ); // Refund hooks. add_action( 'woocommerce_order_fully_refunded', array( $this, 'track_order_refunded' ), 10, 2 ); add_action( 'woocommerce_order_partially_refunded', array( $this, 'track_order_refunded' ), 10, 2 ); // Status change tracking. add_action( 'woocommerce_order_status_changed', array( $this, 'track_order_status_changed' ), 10, 3 ); // Cart tracking: catches form-posted adds the JS handlers never see. add_action( 'woocommerce_add_to_cart', array( $this, 'track_product_added' ), 10, 6 ); } /** * Set the attribution engine instance. * * @param APHA_Attribution $attribution Attribution engine. * @return void */ public function set_attribution( APHA_Attribution $attribution ) { $this->attribution = $attribution; } /** * Track a Product Added event server-side. * * The JS handlers only ever see AJAX adds: jQuery's `added_to_cart` fires for * .ajax_add_to_cart buttons on archives, `wc-blocks_added_to_cart` for the * block cart. A single product page that posts its form — every variable, * bundled, composite or optioned product — reloads and fires neither. * * @param string $cart_item_key Cart item key. * @param int $product_id Product ID. * @param int $quantity Quantity added. * @param int $variation_id Variation ID, 0 when not a variation. * @param array $variation Variation attributes. * @param array $cart_item_data Extra cart item data. * @return void */ public function track_product_added( $cart_item_key, $product_id, $quantity, $variation_id = 0, $variation = array(), $cart_item_data = array() ) { /* * Skip the child items of a bundle or composite so a single add is one * event rather than one per component. Current Product Bundles and * Composite Products releases route child items through their own * `woocommerce_bundled_add_to_cart` / `woocommerce_composited_add_to_cart` * actions and never reach this hook, so this is inert for them — it * guards against the setups that do add child items the standard way. */ if ( isset( $cart_item_data['bundled_by'] ) || isset( $cart_item_data['composite_parent'] ) ) { return; } $distinct_id = $this->identity->get_distinct_id(); if ( empty( $distinct_id ) ) { return; } $product = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $product ) { return; } $properties = $this->product_data->get_product_properties( $product ); $properties['quantity'] = (int) $quantity; $this->api->capture( $distinct_id, 'Product Added', $properties ); } /** * Track an Order Completed event in PostHog. * * Uses both an in-memory static guard (prevents TOCTOU race when * multiple hooks fire in the same request) and order meta for * cross-request deduplication. * * @param int $order_id WooCommerce order ID. * @return void */ public function track_order_completed( $order_id ) { // In-memory guard: prevent duplicate tracking in the same request. if ( isset( self::$tracked_orders[ $order_id ] ) ) { return; } self::$tracked_orders[ $order_id ] = true; $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Cross-request deduplication via order meta. $already_tracked = $order->get_meta( '_apha_tracked' ); if ( ! empty( $already_tracked ) ) { return; } // Identify first so the person merge resolves before the event is captured. $this->identity->identify_from_order( $order ); // Use the stored browser distinct_id so the event links to the browser session. // identify_from_order() above already merges anonymous/wp_XX identity with email. $distinct_id = $this->identity->get_distinct_id_for_order( $order ); $properties = $this->product_data->get_order_properties( $order ); // For upsell orders (created by upsell plugins, bypassing persist_to_order), // read the order group cookie and mark as "upsell". if ( empty( $order->get_meta( '_apha_order_group_id' ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $group_cookie = isset( $_COOKIE['apha_order_group_id'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['apha_order_group_id'] ) ) : ''; if ( ! empty( $group_cookie ) ) { $order->update_meta_data( '_apha_order_group_id', $group_cookie ); /** * Filter the order type for upsell detection. * * @param string $order_type Default order type. * @param WC_Order $order The WooCommerce order. */ $order_type = apply_filters( 'apha_order_type', 'upsell', $order ); $order->update_meta_data( '_apha_order_type', sanitize_text_field( $order_type ) ); } } // Merge attribution data into event properties. if ( $this->attribution ) { $attribution_data = $this->attribution->get_order_attribution( $order ); $properties = array_merge( $properties, $attribution_data ); } // Blocking call to ensure the event is delivered before we mark it as tracked. $success = $this->api->capture( $distinct_id, 'Order Completed', $properties, true ); if ( $success ) { $order->update_meta_data( '_apha_tracked', current_time( 'mysql' ) ); $order->save(); } } /** * Track an Order Refunded event in PostHog. * * Uses per-refund deduplication meta to prevent duplicate refund events. * * @param int $order_id WooCommerce order ID. * @param int $refund_id WooCommerce refund ID. * @return void */ public function track_order_refunded( $order_id, $refund_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Refund deduplication. $refund_meta_key = '_apha_refund_' . $refund_id . '_tracked'; if ( ! empty( $order->get_meta( $refund_meta_key ) ) ) { return; } $refund = wc_get_order( $refund_id ); if ( ! $refund ) { return; } $distinct_id = $this->identity->get_distinct_id_for_order( $order ); $properties = array( 'order_id' => $order->get_id(), 'refund_id' => $refund_id, 'total' => abs( (float) $refund->get_total() ), 'currency' => $order->get_currency(), ); // Merge attribution data into refund events too. if ( $this->attribution ) { $attribution_data = $this->attribution->get_order_attribution( $order ); $properties = array_merge( $properties, $attribution_data ); } // Include refunded line items if this is a partial refund. $refund_items = $refund->get_items(); if ( ! empty( $refund_items ) ) { $products = array(); foreach ( $refund_items as $refund_item ) { if ( ! $refund_item instanceof WC_Order_Item_Product ) { continue; } $product = $refund_item->get_product(); $quantity = abs( $refund_item->get_quantity() ); $unit_price = $quantity > 0 ? abs( (float) $refund_item->get_subtotal() ) / $quantity : 0.0; $product_entry = array( 'product_id' => $refund_item->get_variation_id() ? $refund_item->get_variation_id() : $refund_item->get_product_id(), 'sku' => $product ? $product->get_sku() : '', 'name' => $refund_item->get_name(), 'price' => $unit_price, 'quantity' => $quantity, ); $products[] = $product_entry; } if ( ! empty( $products ) ) { $properties['products'] = $products; } } $this->api->capture( $distinct_id, 'Order Refunded', $properties ); // Mark this refund as tracked. $order->update_meta_data( $refund_meta_key, current_time( 'mysql' ) ); $order->save(); } /** * Track an Order Status Changed event in PostHog. * * @param int $order_id WooCommerce order ID. * @param string $old_status Previous order status slug. * @param string $new_status New order status slug. * @return void */ public function track_order_status_changed( $order_id, $old_status, $new_status ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $distinct_id = $this->identity->get_distinct_id_for_order( $order ); $properties = array( 'order_id' => $order->get_id(), 'previous_status' => $old_status, 'new_status' => $new_status, ); $this->api->capture( $distinct_id, 'Order Status Changed', $properties ); } }