repository = $repository; $this->references = $references; } public function register(): void { add_action( 'woocommerce_after_product_object_save', array( $this, 'on_save' ), 20 ); add_action( 'woocommerce_after_product_variation_object_save', array( $this, 'on_save' ), 20 ); } /** * @param mixed $product */ public function on_save( $product ): void { if ( $product instanceof \WC_Product ) { $this->record( $product, self::detect_source() ); } } /** * Records the current price if it differs from the open history row. * * @return bool Whether a new row was written. */ public function record( \WC_Product $product, string $source ): bool { if ( ! $product->get_id() || ! PriceResolver::is_trackable( $product ) ) { return false; } $snapshot = PriceResolver::snapshot( $product ); if ( null === $snapshot ) { return false; } $currency = PriceResolver::currency(); $open = $this->repository->get_open( $product->get_id(), $currency ); if ( null !== $open && self::unchanged( $open, $snapshot ) ) { return false; } $change = new Change(); $change->product_id = $product->get_id(); $change->parent_id = $product->get_parent_id(); $change->currency = $currency; $change->price = $snapshot['price']; $change->regular_price = $snapshot['regular']; $change->reduced = $snapshot['reduced']; $change->valid_from = Repository::to_datetime( $this->valid_from( $product, $source, $open, $snapshot['reduced'] ) ); $change->source = $source; $change->user_id = self::responsible_user( $source ); $this->repository->insert_change( $change ); $this->references->refresh( $product ); /** * Fires after Strikewise recorded a price change. * * @param Change $change * @param \WC_Product $product */ do_action( 'strikewise_price_recorded', $change, $product ); return true; } /** * When did the new price start to apply? * * Usually "now". For a scheduled sale the start date set in WooCommerce is * used, because the WooCommerce cron may run hours later. * * @param \WC_Product $product * @param string $source * @param array|null $open * @param bool $reduced */ private function valid_from( \WC_Product $product, string $source, ?array $open, bool $reduced ): int { $now = time(); $from = $now; if ( $reduced ) { $sale_start = $product->get_date_on_sale_from( 'edit' ); if ( $sale_start instanceof \WC_DateTime && $sale_start->getTimestamp() <= $now ) { $from = $sale_start->getTimestamp(); } elseif ( 'schedule' === $source ) { // The WooCommerce cron clears the start date; scheduled sales start at midnight shop time. $from = ( new \DateTimeImmutable( 'today', wp_timezone() ) )->getTimestamp(); } } // Never reach back before the price that applied until now. if ( null !== $open ) { $from = max( $from, Repository::to_timestamp( $open['valid_from'] ) ); } return min( $from, $now ); } /** * @param array $open * @param array{price: float, regular: float|null, reduced: bool} $snapshot */ public static function unchanged( array $open, array $snapshot ): bool { $regular = null === $open['regular_price'] ? null : (float) $open['regular_price']; return abs( (float) $open['price'] - $snapshot['price'] ) < 0.00005 && (bool) $open['is_reduced'] === $snapshot['reduced'] && ( null === $regular ? null === $snapshot['regular'] : null !== $snapshot['regular'] && abs( $regular - $snapshot['regular'] ) < 0.00005 ); } /** * Only changes made by a person get a user; automatic ones (cron, sync, * storefront self-healing) would otherwise log whoever happened to be logged in. */ private static function responsible_user( string $source ): ?int { if ( ! in_array( $source, array( 'editor', 'rest', 'cli' ), true ) ) { return null; } $user_id = get_current_user_id(); return $user_id ? $user_id : null; } private static function detect_source(): string { if ( doing_action( 'woocommerce_scheduled_sales' ) ) { return 'schedule'; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return 'rest'; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'cli'; } if ( wp_doing_cron() ) { return 'cron'; } if ( is_admin() ) { return 'editor'; } return 'other'; } }