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 ); // An order saved straight into a paid status records no status transition — // WC_Order::set_status() only records one once object_read is true — so the // hook above never runs for it. Deferred to shutdown, see queue_new_order(). add_action( 'woocommerce_new_order', array( $this, 'queue_new_order' ), 10, 1 ); // Last resort for a paid order that reached neither hook: the customer is on // the order-received page. Themes can override the classic template and the // block version needs view permission, so this is a net, not a primary path. add_action( 'woocommerce_thankyou', array( $this, 'track_order_if_paid' ), 10, 1 ); } /** * 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 ); } /** * Note a newly created order and check whether it is paid on shutdown. * * WC_Abstract_Order::save() persists line items *after* the data store fires * woocommerce_new_order, so reading the order back here would capture an * event with no products and a zero total. Waiting until shutdown lets the * save finish, and avoids a nested save() from inside the outer one. * * @param int $order_id WooCommerce order ID. * @return void */ public function queue_new_order( $order_id ) { if ( empty( $this->pending_new_orders ) ) { add_action( 'shutdown', array( $this, 'flush_pending_new_orders' ) ); } $this->pending_new_orders[] = $order_id; } /** * Check the orders queued by queue_new_order() once the request is done. * * @return void */ public function flush_pending_new_orders() { $order_ids = $this->pending_new_orders; $this->pending_new_orders = array(); foreach ( $order_ids as $order_id ) { $this->track_order_if_paid( $order_id ); } } /** * Track an order that is already in a paid status. * * Entry point for hooks that carry no status-change context, so the paid * check happens here rather than on the transition. Deduplication is handled * by track_order_completed(). * * @param int $order_id WooCommerce order ID. * @return void */ public function track_order_if_paid( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } // Unpaid orders stay untracked: a bank-transfer customer reaches the // order-received page while the order is still on-hold. if ( ! in_array( $order->get_status(), wc_get_is_paid_statuses(), true ) ) { return; } $this->track_order_completed( $order_id ); } /** * 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. * * Also acts as the gateway-agnostic trigger for Order Completed — see the * inline note below. * * @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 ); /* * Gateways that set the paid status directly — HitPay and most local * PSPs — never call WC_Order::payment_complete(), so * woocommerce_payment_complete never fires and the order goes * untracked. This transition fires for every order regardless of * gateway; the static guard and the _apha_tracked meta in * track_order_completed() make the overlapping paths safe. * * wc_get_is_paid_statuses() is called here rather than in the * constructor because this plugin loads before WooCommerce. */ if ( in_array( $new_status, wc_get_is_paid_statuses(), true ) ) { $this->track_order_completed( $order_id ); } } }