0); a new product is * `post_created`. Stock that an ORDER reduces is not a product row: * `wc_update_product_stock()` writes the quantity straight to the database * and reads it back into the object's data (`read_stock_quantity()` flips * object-read off to do exactly that), so it never appears in get_changes(). * The order's own status row stands for it. A stock STATUS the order flips * (in stock -> out of stock) does appear, and is logged: that is a change a * shop owner wants to see. * * Duplicates, both directions: * - Orders stored as posts (`shop_order`, `shop_order_refund`, * `shop_order_placehold`) are no longer post events at all * (EventLogger::INTERNAL_POST_TYPES): every order save wrote a * "Shop_order ... updated" row, and a status change wrote one beside this * class's. * - A product saved from the edit screen is a core post save AND a * WooCommerce object save. When this class logged a price/stock row for a * product this request, EventLogger skips that product's `post_updated` * row if the post save itself changed none of its tracked fields * (was_logged_this_request()); a title edit in the same save still gets * its own row. * * Nothing about a customer is read: no name, address, email or total. The * order is its id; the row is its two statuses. * * Guarded: register() does nothing unless WooCommerce is loaded, and the * event-type labels are listed only then. Every other integration (forms, * LMS, SEO, membership) is a named deferral. */ final class WooCommerceEvents { public const ORDER_STATUS = 'wc_order_status_changed'; public const PRODUCT_PRICE = 'wc_product_price_changed'; public const PRODUCT_STOCK = 'wc_product_stock_changed'; private const PRICE_PROPS = array( 'regular_price', 'sale_price' ); private const STOCK_PROPS = array( 'stock_quantity', 'stock_status' ); private const STATUS_LENGTH = 40; private const PRODUCT_LENGTH = 200; /** * Products this class logged this request: id => true. * * @var array */ private static array $logged = array(); /** * Whether WooCommerce is running on this request. It loads as a plugin, * before `plugins_loaded`, which is when this plugin boots. */ public static function is_active(): bool { return class_exists( 'WooCommerce', false ); } /** * @return array Event type => label for the log's filter. */ public static function event_types(): array { return array( self::ORDER_STATUS => 'Order Status Changed', self::PRODUCT_PRICE => 'Product Price Changed', self::PRODUCT_STOCK => 'Product Stock Changed', ); } /** * @param bool|null $active Whether WooCommerce is active; null asks is_active(). Tests pass it. */ public static function register( ?bool $active = null ): void { if ( ! ( $active ?? self::is_active() ) ) { return; } add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'log_order_status' ), 10, 3 ); add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'log_product_changes' ) ); } /** * @param mixed $order_id The order. * @param mixed $from Status before, without the `wc-` prefix. * @param mixed $to Status after. */ public static function log_order_status( $order_id, $from, $to ): void { $order_id = (int) $order_id; $from = EventLogger::bounded( sanitize_key( (string) $from ), self::STATUS_LENGTH ); $to = EventLogger::bounded( sanitize_key( (string) $to ), self::STATUS_LENGTH ); if ( $order_id <= 0 || '' === $to || $from === $to ) { return; } EventLogger::log( self::ORDER_STATUS, sprintf( 'Order #%1$d status changed from %2$s to %3$s', $order_id, '' !== $from ? $from : 'none', $to ), 'shop_order', $order_id, array( 'from' => $from, 'to' => $to, ) ); } /** * What a pending product save changes among the tracked props: prop => * [before, after], each as text. * * @param array $data get_data(): as read. * @param array $changes get_changes(): pending. * @param string[] $props Which props to compare. * @return array */ public static function diff( array $data, array $changes, array $props ): array { $diff = array(); foreach ( $props as $prop ) { if ( ! array_key_exists( $prop, $changes ) ) { continue; } $before = self::text( $data[ $prop ] ?? null ); $after = self::text( $changes[ $prop ] ); if ( $before !== $after ) { $diff[ $prop ] = array( 'before' => $before, 'after' => $after, ); } } return $diff; } /** * `woocommerce_before_product_object_save`. * * @param mixed $product The WC_Product about to be saved. */ public static function log_product_changes( $product ): void { if ( ! is_object( $product ) || ! method_exists( $product, 'get_id' ) || ! method_exists( $product, 'get_changes' ) || ! method_exists( $product, 'get_data' ) ) { return; } $id = (int) $product->get_id(); if ( $id <= 0 ) { return; } $changes = $product->get_changes(); $data = $product->get_data(); if ( ! is_array( $changes ) || ! is_array( $data ) ) { return; } $name = EventLogger::bounded( method_exists( $product, 'get_name' ) ? (string) $product->get_name( 'edit' ) : (string) $id, self::PRODUCT_LENGTH ); $price = self::diff( $data, $changes, self::PRICE_PROPS ); if ( array() !== $price ) { self::$logged[ $id ] = true; EventLogger::log( self::PRODUCT_PRICE, sprintf( 'Product "%1$s" price changed: %2$s', $name, self::describe( $price ) ), 'product', $id, $price ); } $stock = self::diff( $data, $changes, self::STOCK_PROPS ); if ( array() !== $stock ) { self::$logged[ $id ] = true; EventLogger::log( self::PRODUCT_STOCK, sprintf( 'Product "%1$s" stock changed: %2$s', $name, self::describe( $stock ) ), 'product', $id, $stock ); } } /** * Whether this class logged a price or stock row for this product in * this request. EventLogger's post_updated dedup reads it. * * @param int $post_id A product or variation id. */ public static function was_logged_this_request( int $post_id ): bool { return isset( self::$logged[ $post_id ] ); } /** * @param array $diff A diff(). */ private static function describe( array $diff ): string { $labels = array( 'regular_price' => 'regular price', 'sale_price' => 'sale price', 'stock_quantity' => 'stock', 'stock_status' => 'stock status', ); $parts = array(); foreach ( $diff as $prop => $pair ) { $parts[] = sprintf( '%1$s %2$s to %3$s', $labels[ $prop ] ?? $prop, self::shown( $pair['before'] ), self::shown( $pair['after'] ) ); } return implode( '; ', $parts ); } /** * @param string|null $value A diff side. */ private static function shown( ?string $value ): string { return null === $value || '' === $value ? 'none' : $value; } /** * A prop value as text: prices are decimal strings, stock an int or null, * status a key. Anything else is not a value this class records. * * @param mixed $value A prop value. */ private static function text( $value ): ?string { if ( null === $value ) { return null; } return is_scalar( $value ) ? EventLogger::bounded( (string) $value, 40 ) : null; } /** * Forget this request's products. Tests only. */ public static function reset(): void { self::$logged = array(); } }