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 ); } /** * Set the attribution engine instance. * * @param APHA_Attribution $attribution Attribution engine. * @return void */ public function set_attribution( APHA_Attribution $attribution ) { $this->attribution = $attribution; } /** * 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 ); } }