deferred[ $event . '|' . $key ] = array( $event, $builder ); } /** * Dispatch now, once per key per request. * * @param string $event Event key. * @param string $key De-duplication key. * @param callable $builder Returns the event data, or null to skip. */ private function now( $event, $key, callable $builder ) { $id = $event . '|' . $key; if ( isset( $this->seen[ $id ] ) || ! Endpoints::has_subscribers( $event ) ) { return; } $this->seen[ $id ] = true; $data = call_user_func( $builder ); if ( is_array( $data ) ) { Dispatcher::instance()->dispatch( $event, $data ); } } /** * Build and dispatch deferred events. */ public function flush() { $deferred = $this->deferred; $this->deferred = array(); foreach ( $deferred as $id => $item ) { if ( isset( $this->seen[ $id ] ) ) { continue; } $this->seen[ $id ] = true; try { $data = call_user_func( $item[1] ); } catch ( \Throwable $e ) { $data = null; } if ( is_array( $data ) ) { Dispatcher::instance()->dispatch( $item[0], $data ); } } } /** * Whether an event was already handled in this request. * * @param string $event Event key. * @param string $key Key. * @return bool */ private function handled( $event, $key ) { $id = $event . '|' . $key; return isset( $this->seen[ $id ] ) || isset( $this->deferred[ $id ] ); } // Data builders. /** * Current user as the actor of an action. * * @return array|null */ public static function actor() { $user = wp_get_current_user(); if ( ! $user || ! $user->exists() ) { return null; } return array( 'id' => (int) $user->ID, 'name' => $user->display_name, ); } /** * Visitor IP address. Uses REMOTE_ADDR only; filter it if the site is behind a trusted proxy. * * @return string */ public static function ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip = filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; /** * Filter the client IP included in login events. * * @param string $ip IP address. */ return (string) apply_filters( 'hookbird_client_ip', $ip ); } /** * Visitor user agent. * * @return string */ public static function user_agent() { $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return mb_substr( $agent, 0, 255 ); } /** * Plain text for titles and names: tags removed and all HTML entities decoded * (get_the_title() returns texturized entities such as “). * * @param string $text Text. * @return string */ public static function text( $text ) { return trim( html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } /** * Post data. * * @param \WP_Post|int $post Post. * @return array|null */ public static function post_data( $post ) { $post = get_post( $post ); if ( ! $post ) { return null; } $author = get_userdata( (int) $post->post_author ); $categories = array(); $tags = array(); if ( is_object_in_taxonomy( $post->post_type, 'category' ) ) { $categories = wp_get_post_terms( $post->ID, 'category', array( 'fields' => 'names' ) ); } if ( is_object_in_taxonomy( $post->post_type, 'post_tag' ) ) { $tags = wp_get_post_terms( $post->ID, 'post_tag', array( 'fields' => 'names' ) ); } $excerpt = has_excerpt( $post ) ? $post->post_excerpt : wp_trim_words( excerpt_remove_blocks( strip_shortcodes( $post->post_content ) ), 40, '…' ); $data = array( 'id' => (int) $post->ID, 'type' => $post->post_type, 'title' => self::text( get_the_title( $post ) ), 'status' => $post->post_status, 'slug' => $post->post_name, 'url' => (string) get_permalink( $post ), 'edit_url' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ), 'excerpt' => wp_strip_all_tags( $excerpt ), 'author' => array( 'id' => (int) $post->post_author, 'name' => $author ? $author->display_name : '', ), 'categories' => is_array( $categories ) ? array_values( $categories ) : array(), 'tags' => is_array( $tags ) ? array_values( $tags ) : array(), 'date_gmt' => mysql2date( 'c', $post->post_date_gmt, false ), 'modified_gmt' => mysql2date( 'c', $post->post_modified_gmt, false ), ); /** * Filter post data included in events. * * @param array $data Data. * @param \WP_Post $post Post. */ return (array) apply_filters( 'hookbird_post_data', $data, $post ); } /** * User data. * * @param \WP_User|int $user User. * @return array|null */ public static function user_data( $user ) { $user = $user instanceof \WP_User ? $user : get_userdata( (int) $user ); if ( ! $user || ! $user->exists() ) { return null; } $data = array( 'id' => (int) $user->ID, 'username' => $user->user_login, 'email' => $user->user_email, 'display_name' => $user->display_name, 'first_name' => (string) get_user_meta( $user->ID, 'first_name', true ), 'last_name' => (string) get_user_meta( $user->ID, 'last_name', true ), 'roles' => array_values( (array) $user->roles ), 'registered_gmt' => mysql2date( 'c', $user->user_registered, false ), 'edit_url' => admin_url( 'user-edit.php?user_id=' . $user->ID ), ); /** * Filter user data included in events. * * @param array $data Data. * @param \WP_User $user User. */ return (array) apply_filters( 'hookbird_user_data', $data, $user ); } /** * Comment data. * * @param \WP_Comment|int $comment Comment. * @return array|null */ public static function comment_data( $comment ) { $comment = get_comment( $comment ); if ( ! $comment ) { return null; } $status = wp_get_comment_status( $comment ); $post = get_post( (int) $comment->comment_post_ID ); return array( 'comment' => array( 'id' => (int) $comment->comment_ID, 'author' => $comment->comment_author, 'author_email' => $comment->comment_author_email, 'author_url' => $comment->comment_author_url, 'content' => wp_strip_all_tags( $comment->comment_content ), 'status' => is_string( $status ) ? $status : '', 'type' => $comment->comment_type ? $comment->comment_type : 'comment', 'url' => (string) get_comment_link( $comment ), 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $comment->comment_ID ), 'date_gmt' => mysql2date( 'c', $comment->comment_date_gmt, false ), ), 'post' => $post ? array( 'id' => (int) $post->ID, 'title' => self::text( get_the_title( $post ) ), 'url' => (string) get_permalink( $post ), ) : null, ); } /** * Plugin data from its main file. * * @param string $file Plugin file relative to the plugins directory. * @return array */ public static function plugin_data( $file ) { $data = array( 'file' => (string) $file, 'name' => (string) $file, 'version' => '', 'author' => '', ); $path = WP_PLUGIN_DIR . '/' . $file; if ( file_exists( $path ) ) { if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $info = get_plugin_data( $path, false, false ); $data['name'] = $info['Name'] ? $info['Name'] : $data['name']; $data['version'] = (string) $info['Version']; $data['author'] = wp_strip_all_tags( (string) $info['Author'] ); } return $data; } /** * Theme data. * * @param \WP_Theme|null $theme Theme. * @return array|null */ public static function theme_data( $theme ) { if ( ! $theme instanceof \WP_Theme || ! $theme->exists() ) { return null; } return array( 'name' => (string) $theme->get( 'Name' ), 'stylesheet' => $theme->get_stylesheet(), 'version' => (string) $theme->get( 'Version' ), ); } /** * WooCommerce order data. * * @param \WC_Order|int $order Order. * @return array|null */ public static function order_data( $order ) { if ( ! function_exists( 'wc_get_order' ) ) { return null; } $order = $order instanceof \WC_Abstract_Order ? $order : wc_get_order( $order ); if ( ! $order || ! is_a( $order, 'WC_Order' ) ) { return null; } $items = array(); $summary = array(); $count = 0; foreach ( $order->get_items() as $item ) { if ( ! is_a( $item, 'WC_Order_Item_Product' ) ) { continue; } $product = $item->get_product(); $quantity = (int) $item->get_quantity(); $items[] = array( 'product_id' => (int) $item->get_product_id(), 'variation_id' => (int) $item->get_variation_id(), 'name' => $item->get_name(), 'sku' => $product ? (string) $product->get_sku() : '', 'quantity' => $quantity, 'total' => (float) $item->get_total(), ); $summary[] = $quantity . ' × ' . $item->get_name(); $count += $quantity; } $address = static function ( $type ) use ( $order ) { $fields = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ); if ( 'billing' === $type ) { $fields[] = 'email'; $fields[] = 'phone'; } $out = array(); foreach ( $fields as $field ) { $getter = 'get_' . $type . '_' . $field; $out[ $field ] = is_callable( array( $order, $getter ) ) ? (string) $order->$getter() : ''; } return $out; }; $created = $order->get_date_created(); $currency = $order->get_currency(); $total = (float) $order->get_total(); $data = array( 'id' => (int) $order->get_id(), 'number' => (string) $order->get_order_number(), 'status' => $order->get_status(), 'currency' => $currency, 'total' => $total, 'subtotal' => (float) $order->get_subtotal(), 'total_tax' => (float) $order->get_total_tax(), 'shipping_total' => (float) $order->get_shipping_total(), 'discount_total' => (float) $order->get_discount_total(), 'total_formatted' => number_format( $total, (int) wc_get_price_decimals(), '.', '' ) . ' ' . $currency, 'payment_method' => $order->get_payment_method(), 'payment_method_title' => $order->get_payment_method_title(), 'customer_note' => $order->get_customer_note(), 'created_gmt' => $created ? $created->date( 'c' ) : '', 'customer' => array( 'id' => (int) $order->get_customer_id(), 'name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), ), 'billing' => $address( 'billing' ), 'shipping' => $address( 'shipping' ), 'items' => $items, 'items_summary' => implode( ', ', $summary ), 'item_count' => $count, 'coupons' => array_values( $order->get_coupon_codes() ), 'edit_url' => $order->get_edit_order_url(), ); /** * Filter order data included in events. * * @param array $data Data. * @param \WC_Order $order Order. */ return (array) apply_filters( 'hookbird_order_data', $data, $order ); } /** * WooCommerce product data. * * @param \WC_Product|int $product Product. * @return array|null */ public static function product_data( $product ) { if ( ! function_exists( 'wc_get_product' ) ) { return null; } $product = $product instanceof \WC_Product ? $product : wc_get_product( $product ); if ( ! $product ) { return null; } $quantity = $product->get_stock_quantity(); return array( 'id' => (int) $product->get_id(), 'name' => $product->get_name(), 'sku' => (string) $product->get_sku(), 'stock_quantity' => null === $quantity ? null : (int) $quantity, 'stock_status' => $product->get_stock_status(), 'url' => (string) $product->get_permalink(), 'edit_url' => admin_url( 'post.php?post=' . ( $product->get_parent_id() ? $product->get_parent_id() : $product->get_id() ) . '&action=edit' ), ); } // Posts. /** * Post types that produce events. * * @return string[] */ public static function post_types() { $types = get_post_types( array( 'public' => true ) ); unset( $types['attachment'] ); /** * Filter post types that produce post events. * * @param string[] $types Post type names. */ return (array) apply_filters( 'hookbird_post_types', array_values( $types ) ); } /** * Post status transitions. * * @param string $new_status New status. * @param string $old_status Old status. * @param \WP_Post $post Post. */ public function on_post_transition( $new_status, $old_status, $post ) { if ( ! $post instanceof \WP_Post || wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return; } if ( ! in_array( $post->post_type, self::post_types(), true ) ) { return; } // The block editor saves legacy meta boxes in a second request right after the real save. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check of a core flag. if ( isset( $_REQUEST['meta-box-loader'] ) ) { return; } $event = ''; if ( 'publish' === $new_status && 'publish' !== $old_status ) { $event = 'post.published'; } elseif ( 'publish' === $new_status && 'publish' === $old_status ) { $event = 'post.updated'; } elseif ( 'trash' === $new_status && 'trash' !== $old_status ) { $event = 'post.trashed'; } if ( '' === $event ) { return; } // A post published in this request should not also send "updated". if ( 'post.updated' === $event && $this->handled( 'post.published', (string) $post->ID ) ) { return; } $post_id = (int) $post->ID; $actor = self::actor(); $this->defer( $event, (string) $post_id, static function () use ( $post_id, $actor, $old_status ) { $data = self::post_data( $post_id ); return $data ? array( 'post' => $data, 'previous_status' => $old_status, 'actor' => $actor, ) : null; } ); } // Users. /** * New user. * * @param int $user_id User ID. */ public function on_user_register( $user_id ) { $user_id = (int) $user_id; $actor = self::actor(); $this->defer( 'user.registered', (string) $user_id, static function () use ( $user_id, $actor ) { $data = self::user_data( $user_id ); return $data ? array( 'user' => $data, 'actor' => $actor, ) : null; } ); } /** * Profile updated. * * @param int $user_id User ID. */ public function on_profile_update( $user_id ) { $user_id = (int) $user_id; if ( $this->handled( 'user.registered', (string) $user_id ) ) { return; } $actor = self::actor(); $this->defer( 'user.updated', (string) $user_id, static function () use ( $user_id, $actor ) { $data = self::user_data( $user_id ); return $data ? array( 'user' => $data, 'actor' => $actor, ) : null; } ); } /** * Role changed. * * @param int $user_id User ID. * @param string $role New role. * @param string[] $old_roles Previous roles. */ public function on_set_user_role( $user_id, $role, $old_roles ) { $user_id = (int) $user_id; $old_roles = array_values( (array) $old_roles ); // Roles are set during registration too; only report real changes. if ( empty( $old_roles ) || $this->handled( 'user.registered', (string) $user_id ) || in_array( $role, $old_roles, true ) ) { return; } $actor = self::actor(); $this->defer( 'user.role_changed', (string) $user_id, static function () use ( $user_id, $role, $old_roles, $actor ) { $data = self::user_data( $user_id ); return $data ? array( 'user' => $data, 'role' => (string) $role, 'previous_roles' => $old_roles, 'actor' => $actor, ) : null; } ); } /** * User about to be deleted (data is captured before deletion). * * @param int $user_id User ID. */ public function on_delete_user( $user_id ) { $user_id = (int) $user_id; $this->now( 'user.deleted', (string) $user_id, static function () use ( $user_id ) { $data = self::user_data( $user_id ); return $data ? array( 'user' => $data, 'actor' => self::actor(), ) : null; } ); } /** * Successful login. * * @param string $login Username. * @param \WP_User $user User. */ public function on_login( $login, $user ) { $this->now( 'user.login', $user instanceof \WP_User ? (string) $user->ID : (string) $login, static function () use ( $user ) { $data = self::user_data( $user ); return $data ? array( 'user' => $data, 'ip' => self::ip(), 'user_agent' => self::user_agent(), ) : null; } ); } /** * Failed login. Throttled so a brute force attack cannot flood your channels. * * @param string $username Attempted username. * @param \WP_Error|null $error Error. */ public function on_login_failed( $username, $error = null ) { if ( ! Endpoints::has_subscribers( 'user.login_failed' ) ) { return; } $ip = self::ip(); /** * Filter the maximum failed-login events per IP address in a 10 minute window. * * @param int $limit Limit. */ $per_ip = (int) apply_filters( 'hookbird_login_failed_limit_per_ip', 3 ); /** * Filter the maximum failed-login events for the whole site in a 10 minute window. * * @param int $limit Limit. */ $global = (int) apply_filters( 'hookbird_login_failed_limit_global', 20 ); $ip_key = 'hookbird_lf_' . md5( $ip ); $global_key = 'hookbird_lf_all'; $ip_count = (int) get_transient( $ip_key ); $all_count = (int) get_transient( $global_key ); if ( $ip_count >= $per_ip || $all_count >= $global ) { return; } set_transient( $ip_key, $ip_count + 1, 10 * MINUTE_IN_SECONDS ); set_transient( $global_key, $all_count + 1, 10 * MINUTE_IN_SECONDS ); $reason = $error instanceof \WP_Error ? (string) $error->get_error_code() : ''; $this->now( 'user.login_failed', md5( $ip . '|' . $username . '|' . $all_count ), static function () use ( $username, $ip, $reason ) { return array( 'username' => sanitize_user( (string) $username ), 'ip' => $ip, 'user_agent' => self::user_agent(), 'reason' => $reason, ); } ); } /** * Password reset. * * @param \WP_User $user User. */ public function on_password_reset( $user ) { $this->now( 'user.password_reset', $user instanceof \WP_User ? (string) $user->ID : '0', static function () use ( $user ) { $data = self::user_data( $user ); return $data ? array( 'user' => $data, 'ip' => self::ip(), ) : null; } ); } // Comments. /** * Whether a comment is a real visitor comment rather than internal data stored as a comment * (WooCommerce order notes, block editor notes, Action Scheduler logs and similar). * * @param \WP_Comment $comment Comment. * @return bool */ public static function is_reportable_comment( $comment ) { /** * Filter comment types that never produce comment events. * * @param string[] $types Comment types. */ $excluded = (array) apply_filters( 'hookbird_excluded_comment_types', array( 'order_note', 'note', 'webhook_delivery', 'action_log' ) ); return ! in_array( (string) $comment->comment_type, $excluded, true ); } /** * New comment. * * @param int $comment_id Comment ID. * @param \WP_Comment $comment Comment. */ public function on_comment_insert( $comment_id, $comment ) { if ( $comment instanceof \WP_Comment && ( 'spam' === $comment->comment_approved || ! self::is_reportable_comment( $comment ) ) ) { return; } $comment_id = (int) $comment_id; $this->defer( 'comment.created', (string) $comment_id, static function () use ( $comment_id ) { return self::comment_data( $comment_id ); } ); } /** * Comment status changes. * * @param string $new_status New status. * @param string $old_status Old status. * @param \WP_Comment $comment Comment. */ public function on_comment_status( $new_status, $old_status, $comment ) { if ( 'approved' !== $new_status || 'approved' === $old_status || ! $comment instanceof \WP_Comment || ! self::is_reportable_comment( $comment ) ) { return; } $comment_id = (int) $comment->comment_ID; $this->defer( 'comment.approved', (string) $comment_id, static function () use ( $comment_id ) { return self::comment_data( $comment_id ); } ); } // Plugins, themes and core. /** * Plugin activated. * * @param string $plugin Plugin file. */ public function on_plugin_activated( $plugin ) { $this->now( 'plugin.activated', (string) $plugin, static function () use ( $plugin ) { return array( 'plugin' => self::plugin_data( $plugin ), 'actor' => self::actor(), ); } ); } /** * Plugin deactivated. * * @param string $plugin Plugin file. */ public function on_plugin_deactivated( $plugin ) { if ( plugin_basename( HOOKBIRD_FILE ) === $plugin ) { return; } $this->now( 'plugin.deactivated', (string) $plugin, static function () use ( $plugin ) { return array( 'plugin' => self::plugin_data( $plugin ), 'actor' => self::actor(), ); } ); } /** * Plugin data captured just before its files are removed. * * @var array */ private $deleted_plugins = array(); /** * Remember a plugin's name and version before it is deleted. * * @param string $plugin Plugin file. */ public function remember_deleted_plugin( $plugin ) { if ( Endpoints::has_subscribers( 'plugin.deleted' ) ) { $this->deleted_plugins[ (string) $plugin ] = self::plugin_data( $plugin ); } } /** * Plugin deleted. * * @param string $plugin Plugin file. * @param bool $deleted Whether deletion succeeded. */ public function on_plugin_deleted( $plugin, $deleted ) { if ( ! $deleted ) { return; } $data = isset( $this->deleted_plugins[ $plugin ] ) ? $this->deleted_plugins[ $plugin ] : array( 'file' => (string) $plugin, 'name' => (string) $plugin, 'version' => '', 'author' => '', ); $this->now( 'plugin.deleted', (string) $plugin, static function () use ( $data ) { return array( 'plugin' => $data, 'actor' => self::actor(), ); } ); } /** * Versions before an update, keyed by "plugin:file" or "theme:stylesheet". * * @var array */ private $previous_versions = array(); /** * Remember a plugin's or theme's version before the upgrader replaces it. * * @param mixed $response Response (passed through). * @param array $hook_extra Extra data with "plugin" or "theme". * @return mixed */ public function remember_versions( $response, $hook_extra ) { if ( is_array( $hook_extra ) && ! empty( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) { $data = self::plugin_data( $hook_extra['plugin'] ); $this->previous_versions[ 'plugin:' . $hook_extra['plugin'] ] = $data['version']; } if ( is_array( $hook_extra ) && ! empty( $hook_extra['theme'] ) && is_string( $hook_extra['theme'] ) ) { $theme = wp_get_theme( $hook_extra['theme'] ); $this->previous_versions[ 'theme:' . $hook_extra['theme'] ] = $theme->exists() ? (string) $theme->get( 'Version' ) : ''; } return $response; } /** * Plugin and theme updates. * * @param \WP_Upgrader $upgrader Upgrader. * @param array $options Options with type, action, plugins, themes. */ public function on_upgrade( $upgrader, $options ) { if ( ! is_array( $options ) || empty( $options['action'] ) || 'update' !== $options['action'] ) { return; } $type = isset( $options['type'] ) ? $options['type'] : ''; if ( 'plugin' === $type ) { $plugins = isset( $options['plugins'] ) ? (array) $options['plugins'] : ( isset( $options['plugin'] ) ? array( $options['plugin'] ) : array() ); foreach ( $plugins as $plugin ) { $previous = isset( $this->previous_versions[ 'plugin:' . $plugin ] ) ? $this->previous_versions[ 'plugin:' . $plugin ] : ''; $this->now( 'plugin.updated', (string) $plugin, static function () use ( $plugin, $previous ) { $data = self::plugin_data( $plugin ); $data['previous_version'] = $previous; return array( 'plugin' => $data, 'actor' => self::actor(), ); } ); } } elseif ( 'theme' === $type ) { $themes = isset( $options['themes'] ) ? (array) $options['themes'] : ( isset( $options['theme'] ) ? array( $options['theme'] ) : array() ); foreach ( $themes as $stylesheet ) { $previous = isset( $this->previous_versions[ 'theme:' . $stylesheet ] ) ? $this->previous_versions[ 'theme:' . $stylesheet ] : ''; $this->now( 'theme.updated', (string) $stylesheet, static function () use ( $stylesheet, $previous ) { $data = self::theme_data( wp_get_theme( $stylesheet ) ); if ( $data ) { $data['previous_version'] = $previous; } return $data ? array( 'theme' => $data, 'actor' => self::actor(), ) : null; } ); } } } /** * Theme switched. * * @param string $new_name New theme name. * @param \WP_Theme $new_theme New theme. * @param \WP_Theme $old_theme Old theme. */ public function on_switch_theme( $new_name, $new_theme, $old_theme ) { $this->now( 'theme.switched', $new_theme instanceof \WP_Theme ? $new_theme->get_stylesheet() : (string) $new_name, static function () use ( $new_theme, $old_theme ) { return array( 'theme' => self::theme_data( $new_theme ), 'previous_theme' => self::theme_data( $old_theme ), 'actor' => self::actor(), ); } ); } /** * Core updated. * * @param string $version New version. */ public function on_core_updated( $version ) { $this->now( 'core.updated', (string) $version, static function () use ( $version ) { return array( 'version' => (string) $version, 'actor' => self::actor(), ); } ); } // WooCommerce. /** * New order (built at shutdown so line items are saved). * * @param int $order_id Order ID. */ public function on_new_order( $order_id ) { $order_id = (int) $order_id; $this->defer( 'order.created', (string) $order_id, static function () use ( $order_id ) { $data = self::order_data( $order_id ); // The Checkout block saves a draft order while the customer is still typing. // It is reported once it leaves the draft state. if ( ! $data || self::is_draft_status( $data['status'] ) ) { return null; } return array( 'order' => $data ); } ); } /** * Whether an order status is a draft that customers have not submitted yet. * * @param string $status Status without the "wc-" prefix. * @return bool */ public static function is_draft_status( $status ) { return in_array( (string) $status, array( 'checkout-draft', 'draft', 'auto-draft', 'new' ), true ); } /** * Payment complete. * * @param int $order_id Order ID. */ public function on_payment_complete( $order_id ) { $order_id = (int) $order_id; $this->defer( 'order.paid', (string) $order_id, static function () use ( $order_id ) { $data = self::order_data( $order_id ); return $data ? array( 'order' => $data ) : null; } ); } /** * Order status changed. * * @param int $order_id Order ID. * @param string $from Old status. * @param string $to New status. */ public function on_order_status( $order_id, $from, $to ) { $order_id = (int) $order_id; $from = (string) $from; $to = (string) $to; if ( self::is_draft_status( $to ) ) { return; } if ( self::is_draft_status( $from ) ) { // A Checkout block draft became a real order. $this->on_new_order( $order_id ); } $this->defer( 'order.status_changed', $order_id . ':' . $from . ':' . $to, static function () use ( $order_id, $from, $to ) { $data = self::order_data( $order_id ); return $data ? array( 'order' => $data, 'from' => $from, 'to' => $to, ) : null; } ); if ( 'completed' === $to ) { $this->defer( 'order.completed', (string) $order_id, static function () use ( $order_id ) { $data = self::order_data( $order_id ); return $data ? array( 'order' => $data ) : null; } ); } } /** * Order refunded. * * @param int $order_id Order ID. * @param int $refund_id Refund ID. */ public function on_order_refunded( $order_id, $refund_id ) { $order_id = (int) $order_id; $refund_id = (int) $refund_id; $this->defer( 'order.refunded', (string) $refund_id, static function () use ( $order_id, $refund_id ) { $data = self::order_data( $order_id ); $refund = function_exists( 'wc_get_order' ) ? wc_get_order( $refund_id ) : null; if ( ! $data ) { return null; } $amount = $refund ? (float) $refund->get_amount() : 0.0; return array( 'order' => $data, 'refund' => array( 'id' => $refund_id, 'amount' => number_format( $amount, (int) wc_get_price_decimals(), '.', '' ) . ' ' . $data['currency'], 'value' => $amount, 'reason' => $refund ? (string) $refund->get_reason() : '', ), ); } ); } /** * Low stock. * * @param \WC_Product $product Product. */ public function on_low_stock( $product ) { $this->stock_event( 'product.low_stock', $product ); } /** * Out of stock. * * @param \WC_Product $product Product. */ public function on_no_stock( $product ) { $this->stock_event( 'product.out_of_stock', $product ); } /** * Stock events. * * @param string $event Event. * @param \WC_Product $product Product. */ private function stock_event( $event, $product ) { $id = is_object( $product ) && is_callable( array( $product, 'get_id' ) ) ? (int) $product->get_id() : (int) $product; $this->defer( $event, (string) $id, static function () use ( $id ) { $data = self::product_data( $id ); return $data ? array( 'product' => $data ) : null; } ); } }