render = $render; } /** * Set a tracked product's stock. * * @since 1.0.0 * @param int $id Product or variation ID. * @param mixed $quantity New amount. * @param mixed $expected Amount the merchant saw. * @return array{product: WC_Product, old: int|float, new: int|float, changed: bool}|WP_Error */ public function set_quantity( $id, $quantity, $expected ) { $qty = $this->amount( $quantity, true ); if ( is_wp_error( $qty ) ) { return $qty; } $seen = $this->amount( $expected, true ); if ( is_wp_error( $seen ) ) { return $seen; } $product = $this->product( $id, 'quantity' ); if ( is_wp_error( $product ) ) { return $product; } // true === get_manage_stock(): managing_stock() is also true for a variation using its // parent's stock, and wc_update_product_stock() would then change the parent. if ( true !== $product->get_manage_stock() ) { return $this->not_tracked( $product ); } return $this->locked( $product, fn() => $this->write_quantity( $product, $qty, $seen ) ); } /** * Compare and write a quantity (caller holds the product's lock). * * @since 1.0.0 * @param WC_Product $product Product. * @param int|float $qty New amount. * @param int|float $seen Amount the merchant saw. * @return array{product: WC_Product, old: int|float, new: int|float, changed: bool}|WP_Error */ private function write_quantity( WC_Product $product, $qty, $seen ) { $stored = $this->stored( $product->get_id() ); $old = wc_stock_amount( $stored['_stock'] ?? 0 ); if ( ! $this->same( $old, $seen ) ) { return $this->conflict( $product, 'apwc_stock_changed', /* translators: %s: stock amount now. */ sprintf( __( 'Stock changed to %s since this page loaded (an order may have come in). Enter the new amount again.', 'admin-powerups-for-woocommerce' ), $this->number( $old ) ), array( 'current' => $old ) ); } if ( $this->same( $old, $qty ) ) { return array( 'product' => $product, 'old' => $old, 'new' => $old, 'changed' => false, ); } $delta = $qty - $old; try { wc_update_product_stock( $product, abs( $delta ), $delta > 0 ? 'increase' : 'decrease' ); } catch ( WC_Data_Exception $e ) { return new WP_Error( 'apwc_bad_quantity', $e->getMessage(), array( 'status' => 400 ) ); } $new = wc_stock_amount( $this->stored( $product->get_id() )['_stock'] ?? 0 ); if ( $this->same( $new, $old ) ) { // The UPDATE didn't apply (database error, or a filter replaced WooCommerce's query). return $this->not_saved( $product ); } $product = $this->fresh( $product->get_id() ); $this->after( $product, 'quantity', $old, $new ); return array( 'product' => $product, 'old' => $old, 'new' => $new, 'changed' => true, ); } /** * Set the stock status of a product that doesn't track stock. * * @since 1.0.0 * @param int $id Product or variation ID. * @param mixed $status New status (a key of wc_get_product_stock_status_options()). * @param mixed $expected Status the merchant saw ('' = don't check). * @return array{product: WC_Product, old: string, new: string, changed: bool}|WP_Error */ public function set_status( $id, $status, $expected = '' ) { $options = (array) wc_get_product_stock_status_options(); $status = is_string( $status ) ? $status : ''; if ( '' === $status || ! array_key_exists( $status, $options ) ) { return new WP_Error( 'apwc_bad_status', __( 'Choose one of the listed stock statuses.', 'admin-powerups-for-woocommerce' ), array( 'status' => 400 ) ); } $product = $this->product( $id, 'status' ); if ( is_wp_error( $product ) ) { return $product; } if ( true === $product->get_manage_stock() ) { return $this->conflict( $product, 'apwc_status_computed', __( 'This product tracks stock, so its status follows the amount. Change the amount instead.', 'admin-powerups-for-woocommerce' ) ); } if ( $product->is_type( 'variation' ) && $product->managing_stock() ) { return $this->conflict( $product, 'apwc_parent_stock', __( 'This variation uses its parent product’s stock. Change the stock on the parent row.', 'admin-powerups-for-woocommerce' ) ); } if ( $product->is_type( 'variable' ) ) { return $this->conflict( $product, 'apwc_status_computed', __( 'A variable product’s status comes from its variations. Change them instead.', 'admin-powerups-for-woocommerce' ) ); } return $this->locked( $product, fn() => $this->write_status( $product, $status, $expected, $options ) ); } /** * Compare and write a stock status (caller holds the product's lock). * * @since 1.0.0 * @param WC_Product $product Product. * @param string $status New status. * @param mixed $expected Status the merchant saw ('' = don't check). * @param array $options Status labels. * @return array{product: WC_Product, old: string, new: string, changed: bool}|WP_Error */ private function write_status( WC_Product $product, $status, $expected, array $options ) { $stored = $this->stored( $product->get_id() ); if ( 'yes' === ( $stored['_manage_stock'] ?? '' ) ) { // Tracking was switched on by a save that finished while this one waited. return $this->conflict( $product, 'apwc_status_computed', __( 'This product tracks stock, so its status follows the amount. Change the amount instead.', 'admin-powerups-for-woocommerce' ) ); } $old = (string) ( $stored['_stock_status'] ?? '' ); $old = '' !== $old ? $old : 'instock'; // No status saved: WooCommerce (and the row) treat it as in stock. if ( is_string( $expected ) && '' !== $expected && $expected !== $old ) { return $this->conflict( $product, 'apwc_stock_changed', /* translators: %s: stock status now, e.g. "Out of stock". */ sprintf( __( 'The status changed to “%s” since this page loaded. Choose again.', 'admin-powerups-for-woocommerce' ), (string) ( $options[ $old ] ?? $old ) ), array( 'current' => $old ) ); } if ( $old === $status ) { return array( 'product' => $product, 'old' => $old, 'new' => $old, 'changed' => false, ); } try { $product->set_stock_status( $status ); $product->save(); } catch ( WC_Data_Exception $e ) { return new WP_Error( 'apwc_bad_status', $e->getMessage(), array( 'status' => 400 ) ); } if ( ( $this->stored( $product->get_id() )['_stock_status'] ?? '' ) !== $status ) { return $this->not_saved( $product ); } $product = $this->fresh( $product->get_id() ); $this->after( $product, 'status', $old, $status ); return array( 'product' => $product, 'old' => $old, 'new' => $status, 'changed' => true, ); } /** * Start tracking stock for a product (or a variation that used its parent's stock). * * @since 1.0.0 * @param int $id Product or variation ID. * @param mixed $quantity Starting amount (0 or more). * @return array{product: WC_Product, old: null, new: int|float, changed: bool}|WP_Error */ public function start_tracking( $id, $quantity ) { $qty = $this->amount( $quantity, false ); if ( is_wp_error( $qty ) ) { return $qty; } $product = $this->product( $id, 'tracking' ); if ( is_wp_error( $product ) ) { return $product; } return $this->locked( $product, fn() => $this->write_tracking( $product, $qty ) ); } /** * Switch tracking on (caller holds the product's lock). * * @since 1.0.0 * @param WC_Product $product Product. * @param int|float $qty Starting amount. * @return array{product: WC_Product, old: null, new: int|float, changed: bool}|WP_Error */ private function write_tracking( WC_Product $product, $qty ) { // Re-read under the lock: another save may have switched tracking on meanwhile. if ( 'yes' === ( $this->stored( $product->get_id() )['_manage_stock'] ?? '' ) || true === $product->get_manage_stock() ) { return $this->conflict( $product, 'apwc_already_tracked', __( 'This product already tracks stock. Here is its current amount.', 'admin-powerups-for-woocommerce' ) ); } try { $product->set_manage_stock( true ); $product->set_stock_quantity( $qty ); $product->save(); } catch ( WC_Data_Exception $e ) { return new WP_Error( 'apwc_bad_quantity', $e->getMessage(), array( 'status' => 400 ) ); } $stored = $this->stored( $product->get_id() ); if ( 'yes' !== ( $stored['_manage_stock'] ?? '' ) ) { return $this->not_saved( $product ); } $new = wc_stock_amount( $stored['_stock'] ?? $qty ); $product = $this->fresh( $product->get_id() ); $this->after( $product, 'tracking', null, $new ); return array( 'product' => $product, 'old' => null, 'new' => $new, 'changed' => true, ); } /** * Run a read-compare-write while holding the product's edit lock; 409 when another save of * the same product holds it. * * @since 1.0.0 * @template T of array * @param WC_Product $product Product. * @param callable(): (T|WP_Error) $write The change. * @return T|WP_Error */ private function locked( WC_Product $product, callable $write ) { $name = self::LOCK_PREFIX . $product->get_id(); $token = Lock::acquire( $name, self::LOCK_TTL ); if ( '' === $token ) { return $this->conflict( $product, 'apwc_busy', __( 'Another change to this product is being saved. Try again in a moment.', 'admin-powerups-for-woocommerce' ) ); } try { return $write(); } finally { Lock::release( $name, $token ); } } /** * How many edit lock rows are stored (Data section). * * @since 1.0.0 * @return int */ public static function count_locks(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( self::LOCK_PREFIX ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- counts raw lock rows; never cached. } /** * Delete a batch of edit lock rows (Data section, uninstall). * * @since 1.0.0 * @return int How many are left. */ public static function delete_locks(): int { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s LIMIT %d', $wpdb->options, $wpdb->esc_like( self::LOCK_PREFIX ) . '%', Cleanup::BATCH ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- removes raw lock rows; never cached. return self::count_locks(); } /** * A valid amount, or a 400 error. * * @since 1.0.0 * @param mixed $raw Input. * @param bool $allow_negative Whether below 0 is fine (stock can already be negative after orders). * @return int|float|WP_Error */ private function amount( $raw, $allow_negative ) { $bad = static fn( $message ) => new WP_Error( 'apwc_bad_quantity', $message, array( 'status' => 400 ) ); if ( is_string( $raw ) ) { $raw = trim( $raw ); } if ( ( ! is_int( $raw ) && ! is_float( $raw ) && ! is_string( $raw ) ) || '' === $raw || ! is_numeric( $raw ) ) { return $bad( __( 'Enter a number.', 'admin-powerups-for-woocommerce' ) ); } $num = 0 + $raw; if ( ! is_finite( (float) $num ) || abs( $num ) > self::MAX ) { /* translators: %s: largest amount. */ return $bad( sprintf( __( 'Enter an amount up to %s.', 'admin-powerups-for-woocommerce' ), number_format_i18n( self::MAX ) ) ); } if ( ! $allow_negative && $num < 0 ) { return $bad( __( 'Enter 0 or more.', 'admin-powerups-for-woocommerce' ) ); } $amount = wc_stock_amount( $num ); if ( ! $this->same( $amount, $num ) ) { return $bad( __( 'Enter a whole number.', 'admin-powerups-for-woocommerce' ) ); } return $amount; } /** * The product, when it may be listed and the user may edit it. * * @since 1.0.0 * @param int $id Product or variation ID. * @param string $change quantity|status|tracking. * @return WC_Product|WP_Error */ private function product( $id, $change ) { $missing = new WP_Error( 'apwc_no_product', __( 'This product isn’t available any more. Refresh the page.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); $id = (int) $id; $post = $id > 0 ? get_post( $id ) : null; if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ) { return $missing; } $owner = $post; if ( 'product_variation' === $post->post_type ) { $owner = $post->post_parent ? get_post( $post->post_parent ) : null; if ( ! $owner instanceof WP_Post || 'product' !== $owner->post_type ) { return $missing; } } // Permission first, so status and type checks can't tell someone about products they can't // edit. product_variation has no meta capabilities of its own: check the parent product. $product = $this->fresh( $id ); $can = current_user_can( 'edit_product', $owner->ID ); /** * Filter whether the current user may change a product's stock in Quick stock. Runs after * the edit_product check (for a variation, on its parent). * * @since 1.0.0 * @param bool $can Allowed. * @param WC_Product|null $product Product or variation (null if WooCommerce can't load it). * @param string $change quantity, status or tracking. */ if ( ! apply_filters( 'apwc_quick_stock_can_edit', $can, $product, $change ) ) { return new WP_Error( 'apwc_cannot_edit', __( 'You can’t change this product’s stock.', 'admin-powerups-for-woocommerce' ), array( 'status' => rest_authorization_required_code() ) ); } $statuses = StockQuery::post_statuses(); if ( ! $product instanceof WC_Product || ! in_array( $post->post_status, $statuses, true ) || ! in_array( $owner->post_status, $statuses, true ) ) { return $missing; } $type = $product->is_type( 'variation' ) ? WC_Product_Factory::get_product_type( $owner->ID ) : $product->get_type(); if ( in_array( (string) $type, StockQuery::excluded_types(), true ) ) { return $missing; } return $product; } /** * A product read from the database, not from a cache (a persistent object cache could hold * the stock from before an order). * * @since 1.0.0 * @param int $id Product ID. * @return WC_Product|null */ private function fresh( $id ) { clean_post_cache( $id ); wp_cache_delete( $id, 'post_meta' ); $parent = (int) wp_get_post_parent_id( $id ); if ( $parent ) { // A variation reads some props (and managing_stock()) from its parent. clean_post_cache( $parent ); wp_cache_delete( $parent, 'post_meta' ); } $product = wc_get_product( $id ); return $product instanceof WC_Product ? $product : null; } /** * Stock meta straight from the database (bypasses the object cache). * * @since 1.0.0 * @param int $id Product ID. * @return array */ private function stored( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- must not come from a cache: this is the lost-update check. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM %i WHERE post_id = %d AND meta_key IN ( '_stock', '_manage_stock', '_stock_status' ) ORDER BY meta_id", $wpdb->postmeta, $id ) ); $out = array(); foreach ( (array) $rows as $row ) { if ( ! isset( $out[ $row->meta_key ] ) ) { $out[ $row->meta_key ] = (string) $row->meta_value; } } return $out; } /** * Why a quantity can't be set: tracking is off, or a variation uses its parent's stock. * * @since 1.0.0 * @param WC_Product $product Product. * @return WP_Error */ private function not_tracked( WC_Product $product ) { if ( $product->is_type( 'variation' ) && $product->managing_stock() ) { return $this->conflict( $product, 'apwc_parent_stock', __( 'This variation uses its parent product’s stock. Change the stock on the parent row, or track it separately.', 'admin-powerups-for-woocommerce' ) ); } return $this->conflict( $product, 'apwc_not_tracked', __( 'This product doesn’t track stock any more. Here is how it is now.', 'admin-powerups-for-woocommerce' ) ); } /** * A 500 when WooCommerce's write didn't change anything, with the row as it is now. * * @since 1.0.0 * @param WC_Product $product Product. * @return WP_Error */ private function not_saved( WC_Product $product ) { return new WP_Error( 'apwc_not_saved', __( 'The change wasn’t saved. Here is the product as it is now; try again.', 'admin-powerups-for-woocommerce' ), array( 'status' => 500, 'row_html' => (string) call_user_func( $this->render, $product->get_id() ), ) ); } /** * A 409 with the row as it is now. * * @since 1.0.0 * @param WC_Product $product Product. * @param string $code Error code. * @param string $message Message. * @param array $data Extra data. * @return WP_Error */ private function conflict( WC_Product $product, $code, $message, array $data = array() ) { return new WP_Error( $code, $message, array_merge( $data, array( 'status' => 409, 'row_html' => (string) call_user_func( $this->render, $product->get_id() ), ) ) ); } /** * After a change: parent status, hooks, optional stock emails. * * @since 1.0.0 * @param WC_Product|null $product Product (fresh). * @param string $change quantity|status|tracking. * @param int|float|string|null $before Before. * @param int|float|string|null $after After. * @return void */ private function after( $product, $change, $before, $after ) { if ( ! $product instanceof WC_Product ) { return; } if ( $product->is_type( 'variation' ) && $product->get_parent_id() ) { WC_Product_Variable::sync_stock_status( $product->get_parent_id() ); } /** * Runs after Quick stock changed a product. * * @since 1.0.0 * @param WC_Product $product Product or variation (fresh). * @param string $change quantity, status or tracking. * @param int|float|string|null $old Before (amount, status key; null when tracking started). * @param int|float|string|null $after After. */ do_action( 'apwc_quick_stock_updated', $product, $change, $before, $after ); /** * Filter whether a Quick stock change sends WooCommerce's low / no stock emails. Off by * default, like WooCommerce's own product editor. * * @since 1.0.0 * @param bool $send Send. * @param WC_Product $product Product. */ if ( 'status' !== $change && apply_filters( 'apwc_quick_stock_send_stock_alerts', false, $product ) && function_exists( 'wc_trigger_stock_change_actions' ) ) { wc_trigger_stock_change_actions( $product ); } } /** * Equal amounts. * * @since 1.0.0 * @param int|float $a A. * @param int|float $b B. * @return bool */ private function same( $a, $b ) { return abs( (float) $a - (float) $b ) < 1e-9; } /** * An amount for messages. * * @since 1.0.0 * @param int|float|null $n Amount. * @return string */ public function number( $n ) { if ( null === $n ) { return ''; } $decimals = floor( (float) $n ) === (float) $n ? 0 : 2; return number_format_i18n( (float) $n, $decimals ); } }