Events captured in this request, sent at shutdown. */ private static $pending = array(); private static $shutdown_registered = false; /** @var array Cart and checkout events captured in this request: type, cart id, data, id suffix. */ private static $pending_funnel = array(); public static function init() { // Classic checkout and the block (Store API) checkout use different hooks for "order created". add_action( 'woocommerce_checkout_order_processed', array( __CLASS__, 'on_order_created' ), 20, 1 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( __CLASS__, 'on_store_api_order_created' ), 20, 1 ); add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'on_order_status_changed' ), 20, 4 ); add_action( 'woocommerce_update_order', array( __CLASS__, 'on_order_saved' ), 20, 2 ); // Carts and checkout: for the funnel (how many carts, how many reached checkout, how many finished) add_action( 'woocommerce_add_to_cart', array( __CLASS__, 'on_add_to_cart' ), 20, 0 ); // Whatever changes what is in the cart reports the whole cart as it is now: an item removed, brought back, or its quantity changed add_action( 'woocommerce_cart_item_removed', array( __CLASS__, 'on_cart_changed' ), 20, 0 ); add_action( 'woocommerce_cart_item_restored', array( __CLASS__, 'on_cart_changed' ), 20, 0 ); add_action( 'woocommerce_after_cart_item_quantity_update', array( __CLASS__, 'on_cart_changed' ), 20, 0 ); add_action( 'template_redirect', array( __CLASS__, 'on_checkout_page' ), 20 ); // The visitor typed their phone or email at checkout: WinnyPlus learns who the cart belongs to (classic checkout, then block checkout) add_action( 'woocommerce_checkout_update_order_review', array( __CLASS__, 'on_contact_known' ), 30, 0 ); add_action( 'woocommerce_store_api_cart_update_customer_from_request', array( __CLASS__, 'on_contact_known' ), 30, 0 ); add_action( 'woocommerce_created_customer', array( __CLASS__, 'on_customer_created' ), 20, 1 ); // Products: create/update (variants are part of the parent) and delete or trash add_action( 'woocommerce_new_product', array( __CLASS__, 'on_product_created' ), 20, 1 ); add_action( 'woocommerce_update_product', array( __CLASS__, 'on_product_updated' ), 20, 1 ); add_action( 'woocommerce_update_product_variation', array( __CLASS__, 'on_variation_updated' ), 20, 1 ); add_action( 'wp_trash_post', array( __CLASS__, 'on_post_removed' ), 20, 1 ); add_action( 'before_delete_post', array( __CLASS__, 'on_post_removed' ), 20, 1 ); // Safety-net retry (and the path for any event queued by an older version of the plugin) add_action( 'winnyplus_connect_dispatch_event', array( __CLASS__, 'process_scheduled_event' ), 10, 3 ); } public static function on_order_created( $order_id ) { // The same order can reach both checkout hooks in some setups: capture it once. if ( get_transient( 'winnyplus_connect_created_' . $order_id ) ) { return; } set_transient( 'winnyplus_connect_created_' . $order_id, 1, MINUTE_IN_SECONDS ); // Before the event is built: it carries the cart id and the visitor's source (the browser is making this request, so its cookie is here) WinnyPlus_Connect_Attribution::remember_on_order( wc_get_order( $order_id ) ); self::tie_order_to_cart( $order_id ); self::capture( 'order.created', 'order', $order_id ); } /** The id of the visitor's cart (kept in the WooCommerce session), made when the cart is first used. Null when there is no session. */ private static function cart_id( $create = true ) { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return null; } $id = WC()->session->get( 'winnyplus_connect_cart_id' ); if ( ! $id && $create ) { $id = wp_generate_uuid4(); WC()->session->set( 'winnyplus_connect_cart_id', $id ); WC()->session->set( 'winnyplus_connect_cart_steps', array() ); } return $id ? (string) $id : null; } private static function has_step( $cart_id, $type ) { $steps = WC()->session->get( 'winnyplus_connect_cart_steps' ); return is_array( $steps ) && in_array( $type, $steps, true ); } /** Each step of a cart is sent once. */ private static function step_done( $cart_id, $type ) { $steps = WC()->session->get( 'winnyplus_connect_cart_steps' ); $steps = is_array( $steps ) ? $steps : array(); if ( in_array( $type, $steps, true ) ) { return true; } $steps[] = $type; WC()->session->set( 'winnyplus_connect_cart_steps', $steps ); return false; } /** * A product was added to the cart. The first one starts the cart (cart.created); the cart is then reported again, whole and as it is * now (cart.updated), each time what is in it changes (see on_cart_changed). Only what the visitor does sends anything: no timer, no polling. */ public static function on_add_to_cart() { self::report_cart( true ); } /** An item was removed or brought back, or a quantity changed: the whole cart is reported as it is now. */ public static function on_cart_changed() { self::report_cart( false ); } /** Reports the cart to WinnyPlus; a report that would say what was already said is not sent. */ private static function report_cart( $starts ) { $cart_id = self::cart_id( $starts ); if ( ! $cart_id || ! WC()->cart ) { return; } $created = self::has_step( $cart_id, 'cart.created' ); if ( ! $created && ! $starts ) { return; // nothing was ever added in this session: there is no cart to report } $data = WinnyPlus_Connect_Events::build_cart_data( $cart_id, WC()->cart ); $hash = md5( wp_json_encode( $data['items'] ) ); if ( ! $created ) { self::step_done( $cart_id, 'cart.created' ); WC()->session->set( 'winnyplus_connect_cart_hash', $hash ); self::capture_funnel( 'cart.created', $cart_id, $data ); return; } if ( $hash === WC()->session->get( 'winnyplus_connect_cart_hash' ) ) { return; } WC()->session->set( 'winnyplus_connect_cart_hash', $hash ); // Every report has its own id (the same contents can come back after a change): WinnyPlus keeps the newest self::capture_funnel( 'cart.updated', $cart_id, $data, substr( $hash, 0, 8 ) . '-' . time() ); } /** The customer is on the checkout page (the classic and the block checkout are both a page where is_checkout() is true). */ public static function on_checkout_page() { if ( ! function_exists( 'is_checkout' ) || ! is_checkout() || is_wc_endpoint_url( 'order-received' ) || is_wc_endpoint_url( 'order-pay' ) ) { return; } if ( ! WC()->cart || WC()->cart->is_empty() ) { return; } $cart_id = self::cart_id(); if ( $cart_id && ! self::step_done( $cart_id, 'checkout.started' ) ) { // A cart that was never reported as started (the plugin was switched on after it) is reported now, so the steps stay in order if ( ! self::step_done( $cart_id, 'cart.created' ) ) { self::capture_funnel( 'cart.created', $cart_id, WinnyPlus_Connect_Events::build_cart_data( $cart_id, WC()->cart ) ); } self::capture_funnel( 'checkout.started', $cart_id, WinnyPlus_Connect_Events::build_cart_data( $cart_id, WC()->cart ) ); } } /** The visitor has now given a phone: the checkout is reported again with it, once, so the cart can be answered if it is left. */ public static function on_contact_known() { if ( ! WC()->cart || WC()->cart->is_empty() || ! WC()->customer ) { return; } if ( ! WinnyPlus_Connect_Events::normalize_phone( WC()->customer->get_billing_phone(), WC()->customer->get_billing_country() ) ) { return; } $cart_id = self::cart_id(); if ( $cart_id && ! self::step_done( $cart_id, 'contact' ) ) { self::capture_funnel( 'checkout.started', $cart_id, WinnyPlus_Connect_Events::build_cart_data( $cart_id, WC()->cart ), 'contact' ); } } /** The order remembers the cart it came from, and the visitor's next cart gets a new id. */ private static function tie_order_to_cart( $order_id ) { $cart_id = self::cart_id( false ); $order = wc_get_order( $order_id ); if ( ! $cart_id || ! $order ) { return; } $order->update_meta_data( '_winnyplus_connect_cart_id', $cart_id ); $order->save_meta_data(); WC()->session->set( 'winnyplus_connect_cart_id', null ); WC()->session->set( 'winnyplus_connect_cart_steps', array() ); } /** Queues a cart or checkout event, sent after the page response like every other event. Connected and switched on, or nothing. */ private static function capture_funnel( $type, $cart_id, $data, $extra = '' ) { if ( ! WinnyPlus_Connect_Connection::is_connected() ) { return; } if ( ! WinnyPlus_Connect_Events::is_enabled( $type ) ) { return; } self::$pending_funnel[] = array( $type, $cart_id, $data, $extra ); if ( ! self::$shutdown_registered ) { self::$shutdown_registered = true; add_action( 'shutdown', array( __CLASS__, 'flush_pending' ), 99 ); } } public static function on_store_api_order_created( $order ) { if ( is_object( $order ) && method_exists( $order, 'get_id' ) ) { self::on_order_created( $order->get_id() ); } } public static function on_order_status_changed( $order_id, $from_status, $to_status, $order ) { // WinnyPlus asked for this change: the store does not report it back if ( get_transient( 'winnyplus_connect_from_winnyplus_' . $order_id ) ) { delete_transient( 'winnyplus_connect_from_winnyplus_' . $order_id ); return; } $type = ( 'cancelled' === $to_status ) ? 'order.cancelled' : 'order.updated'; self::note_change( $order_id, 'status', $from_status ); // Paying is a change of its own: cash on delivery moves to processing unpaid and is paid when completed $method = is_object( $order ) && method_exists( $order, 'get_payment_method' ) ? $order->get_payment_method() : ''; if ( WinnyPlus_Connect_Events::is_paid_state( $from_status, $method ) !== WinnyPlus_Connect_Events::is_paid_state( $to_status, $method ) ) { self::note_change( $order_id, 'payment' ); } self::capture( $type, 'order', $order_id ); } /** * An order was saved. When its items or its details (addresses, note, shipping or payment method) are not what we last * saw, that is an edit WinnyPlus should know about. The status is not part of this: it has its own hook. */ public static function on_order_saved( $order_id, $order = null ) { if ( ! WinnyPlus_Connect_Connection::is_connected() ) { return; } $order = $order ? $order : wc_get_order( $order_id ); if ( ! $order ) { return; } // WinnyPlus is changing the items itself (the order-items route): not an edit made in the store if ( get_transient( 'winnyplus_connect_items_from_winnyplus_' . $order_id ) ) { return; } $now = WinnyPlus_Connect_Events::order_hashes( $order ); $known = get_transient( 'winnyplus_connect_hash_' . $order_id ); set_transient( 'winnyplus_connect_hash_' . $order_id, $now, 30 * DAY_IN_SECONDS ); // First time we see this order (or it just got created and is still being completed by checkout): nothing to compare yet $created = $order->get_date_created(); if ( ! is_array( $known ) || ( $created && ( time() - $created->getTimestamp() ) < 90 ) ) { return; } $changed = false; foreach ( array( 'items', 'data' ) as $kind ) { if ( ( $known[ $kind ] ?? null ) !== $now[ $kind ] ) { self::note_change( $order_id, $kind ); $changed = true; } } if ( $changed ) { self::capture( 'order.updated', 'order', $order_id ); } } /** * Remembers what changed on an order until its event has been sent (several changes in one request become one event). * Read by WinnyPlus_Connect_Events::build_order_data(). */ private static function note_change( $order_id, $kind, $status_from = null ) { $pending = get_transient( 'winnyplus_connect_changes_' . $order_id ); $pending = is_array( $pending ) ? $pending : array( 'kinds' => array() ); if ( ! in_array( $kind, $pending['kinds'], true ) ) { $pending['kinds'][] = $kind; } if ( null !== $status_from ) { $pending['status_from'] = (string) $status_from; } set_transient( 'winnyplus_connect_changes_' . $order_id, $pending, HOUR_IN_SECONDS ); } public static function on_customer_created( $customer_id ) { self::capture( 'customer.created', 'customer', $customer_id ); } public static function on_product_created( $product_id ) { self::capture_product( 'product.created', $product_id ); } public static function on_product_updated( $product_id ) { self::capture_product( 'product.updated', $product_id ); } /** A variation change is a change of the parent product. */ public static function on_variation_updated( $variation_id ) { $parent_id = wp_get_post_parent_id( $variation_id ); if ( $parent_id ) { self::capture_product( 'product.updated', $parent_id ); } } public static function on_post_removed( $post_id ) { if ( 'product' !== get_post_type( $post_id ) ) { return; } if ( ! WinnyPlus_Connect_Connection::is_connected() ) { return; } if ( ! WinnyPlus_Connect_Events::is_enabled( 'product.deleted' ) ) { return; } $product = wc_get_product( $post_id ); $sku = $product ? $product->get_sku() : ''; // A deletion is sent right away: the product may be gone before any queued job could read it. $data = WinnyPlus_Connect_Events::build_product_deleted_data( $post_id, $sku ); $ext_id = WinnyPlus_Connect_Events::generate_external_event_id( 'product.deleted', $post_id, (string) time() ); WinnyPlus_Connect_API::send_event( 'product.deleted', $ext_id, $data, null ); } /** * WooCommerce fires "updated" several times for one save (the product, then each variation): one event per * product within 15 seconds. */ private static function capture_product( $type, $product_id ) { if ( ! WinnyPlus_Connect_Connection::is_connected() || ! WinnyPlus_Connect_Events::is_enabled( $type ) ) { return; } if ( get_transient( 'winnyplus_connect_product_lock_' . $product_id ) ) { return; } set_transient( 'winnyplus_connect_product_lock_' . $product_id, 1, 15 ); self::enqueue( $type, 'product', $product_id ); } /** Connected? Switched on in WinnyPlus? If not, a silent no-op apart from a throttled SKIPPED log row. */ private static function capture( $type, $subject, $id ) { if ( ! WinnyPlus_Connect_Connection::is_connected() ) { return; } if ( ! WinnyPlus_Connect_Events::is_enabled( $type ) ) { return; } self::enqueue( $type, $subject, $id ); } private static function enqueue( $type, $subject, $id ) { // The same event twice in one request is one event (what changed is merged into it) foreach ( self::$pending as $queued ) { if ( $queued[0] === $type && $queued[1] === $subject && $queued[2] === (int) $id ) { return; } } self::$pending[] = array( $type, $subject, (int) $id ); if ( ! self::$shutdown_registered ) { self::$shutdown_registered = true; add_action( 'shutdown', array( __CLASS__, 'flush_pending' ), 99 ); } // Safety net in case this request ends before shutdown runs or the send fails wp_schedule_single_event( time() + 90, 'winnyplus_connect_dispatch_event', array( $type, $subject, (int) $id ) ); } /** Sends what this request captured, after the visitor already has the page. */ public static function flush_pending() { if ( empty( self::$pending ) && empty( self::$pending_funnel ) ) { return; } if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } elseif ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); } $funnel = self::$pending_funnel; self::$pending_funnel = array(); foreach ( $funnel as $item ) { list( $type, $cart_id, $data, $extra ) = $item; // The cart's id and the step make the event id: the same step sent twice is recognised as a duplicate WinnyPlus_Connect_API::send_event( $type, WinnyPlus_Connect_Events::generate_external_event_id( $type, $cart_id, $extra ), $data, null ); } $queue = self::$pending; self::$pending = array(); foreach ( $queue as $item ) { list( $type, $subject, $id ) = $item; if ( self::process_scheduled_event( $type, $subject, $id ) ) { wp_clear_scheduled_hook( 'winnyplus_connect_dispatch_event', array( $type, $subject, $id ) ); } } } /** * Builds and sends one event. * * @return bool True when it is handled (sent, or nothing left to send); false when it should be retried. */ public static function process_scheduled_event( $type, $subject, $id ) { try { return self::build_and_send( $type, $subject, $id ); } catch ( \Throwable $e ) { // Building the payload failed (a code or data problem): the safety-net retry tries again, and the request must not die after the response was sent do_action( 'winnyplus_connect_event_failed', $type, $e ); return false; } } private static function build_and_send( $type, $subject, $id ) { // Check again at the moment of sending (state may have changed since the capture) if ( ! WinnyPlus_Connect_Connection::is_connected() || ! WinnyPlus_Connect_Events::is_enabled( $type ) ) { return true; } if ( 'product' === $subject ) { $product = wc_get_product( $id ); if ( ! $product ) { return true; } $modified = $product->get_date_modified() ? $product->get_date_modified()->getTimestamp() : 0; $data = WinnyPlus_Connect_Events::build_product_data( $product ); $ext_id = WinnyPlus_Connect_Events::generate_external_event_id( $type, $id, (string) $modified ); $result = WinnyPlus_Connect_API::send_event( $type, $ext_id, $data, null ); return ! empty( $result['success'] ); } if ( 'order' === $subject ) { $order = wc_get_order( $id ); if ( ! $order ) { return true; } $data = WinnyPlus_Connect_Events::build_order_data( $order ); // Deterministic id: the same change sent twice (request + safety net) is recognised as a duplicate. $modified = $order->get_date_modified() ? $order->get_date_modified()->getTimestamp() : 0; $extra = ( 'order.created' !== $type ) ? $order->get_status() . '-' . $modified . '-' . substr( md5( implode( ',', (array) $data['changes'] ) . '|' . $data['status_from'] ), 0, 6 ) : ''; $ext_id = WinnyPlus_Connect_Events::generate_external_event_id( $type, $id, $extra ); $result = WinnyPlus_Connect_API::send_event( $type, $ext_id, $data, $id ); if ( ! empty( $result['success'] ) ) { delete_transient( 'winnyplus_connect_changes_' . $id ); delete_transient( 'winnyplus_connect_historical_' . $id ); return true; } return false; } $data = WinnyPlus_Connect_Events::build_customer_created_data( $id ); $ext_id = WinnyPlus_Connect_Events::generate_external_event_id( $type, $id ); $result = WinnyPlus_Connect_API::send_event( $type, $ext_id, $data, null ); return ! empty( $result['success'] ); } } WinnyPlus_Connect_Hooks::init();