Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions modules/ppcp-compat/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@
return class_exists( 'WC_Bookings' );
},

'compat.plugin-detector' => static function (): PluginDetector\PluginDetector {
return new PluginDetector\PluginDetector();
},

'compat.product-customization-detector' => static function ( ContainerInterface $container ): PluginDetector\ProductCustomizationDetector {
return new PluginDetector\ProductCustomizationDetector(
$container->get( 'compat.plugin-detector' ),
$container->get( 'woocommerce.logger.woocommerce' )
);
},

'compat.asset_getter' => static function ( ContainerInterface $container ): AssetGetter {
$factory = $container->get( 'assets.asset_getter_factory' );
assert( $factory instanceof AssetGetterFactory );
Expand Down
18 changes: 18 additions & 0 deletions modules/ppcp-compat/src/Exception/PluginApiChangedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* The modules Runtime Exception.
*
* @package WooCommerce\PayPalCommerce\Compat\Exception
*/

declare(strict_types=1);

namespace WooCommerce\PayPalCommerce\Compat\Exception;

/**
* Thrown when an API method of a plugin doesn't exist although that plugin is active.
*/
class PluginApiChangedException extends \RuntimeException {


}
105 changes: 105 additions & 0 deletions modules/ppcp-compat/src/PluginDetector/PluginDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
/**
* Detects third-party plugins relevant for compatibility checks.
*
* @package WooCommerce\PayPalCommerce\Compat\PluginDetector
*/

declare(strict_types=1);

namespace WooCommerce\PayPalCommerce\Compat\PluginDetector;

/**
* @see self::scan() for the plugins list.
*/
class PluginDetector {

/**
* @return array<string, bool> List of plugins check was made for,
* boolean shows whether the plugin is active
*/
Comment on lines +25 to +28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Remove the docblock

Reason: it's 100% noise, adding no value. PhpStorm expands the parent docblock automatically.

Of course, if the interface is removed (as I suggested), move the original docblock here 😉

@strangerkir strangerkir Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The interface was removed, and I only left the file header here, removing the class docblock. It looks like in this project we often prefer file headers, so I followed the same pattern.

public function scan(): array {
return array(
'woocommerce-subscriptions' => $this->is_woocommerce_subscriptions_active(),
'woocommerce-gift-cards' => $this->is_woocommerce_gift_cards_active(),
'woocommerce-product-bundles' => $this->is_woocommerce_product_bundles_active(),
'woocommerce-product-addons' => $this->is_woocommerce_product_addons_active(),
'woocommerce-min-max-quantities' => $this->is_woocommerce_min_max_quantities_active(),
'woocommerce-composite-products' => $this->is_woocommerce_composite_products_active(),
'woocommerce-shipping-per-product' => $this->is_woocommerce_shipping_per_product_active(),
'woocommerce-deposits' => $this->is_woocommerce_deposits_active(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idea: turn these strings into public consts

Reasons:

  • Allows auto-completion in the IDE
  • Avoids typos
  • Gives us a single place with the full detection-catalog

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks! Done.

);
}

/**
* Checks whether WooCommerce Subscriptions is active.
*
* @return bool
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Remove all the docblocks in this file.

Reason: the return value is documented in code. And the function names are excellent, they do not need any explanation

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, this is nothing more than noise. Removed now.

private function is_woocommerce_subscriptions_active(): bool {
return class_exists( \WC_Subscriptions::class );
}

/**
* Checks whether WooCommerce Gift Cards is active.
*
* @return bool
*/
private function is_woocommerce_gift_cards_active(): bool {
return function_exists( 'WC_GC' );
}

/**
* Checks whether WooCommerce Product Bundles is active.
*
* @return bool
*/
private function is_woocommerce_product_bundles_active(): bool {
return class_exists( \WC_Bundles::class );
}

/**
* Checks whether WooCommerce Product Add-Ons is active.
*
* @return bool
*/
private function is_woocommerce_product_addons_active(): bool {
return class_exists( \WC_Product_Addons::class );
}

/**
* Checks whether WooCommerce Min/Max Quantities is active.
*
* @return bool
*/
private function is_woocommerce_min_max_quantities_active(): bool {
return class_exists( \WC_Min_Max_Quantities::class );
}

/**
* Checks whether WooCommerce Composite Products is active.
*
* @return bool
*/
private function is_woocommerce_composite_products_active(): bool {
return class_exists( \WC_Composite_Products::class );
}

/**
* Checks whether WooCommerce Per-Product Shipping is active.
*
* @return bool
*/
private function is_woocommerce_shipping_per_product_active(): bool {
return class_exists( \WC_Shipping_Per_Product_Init::class );
}

/**
* Checks whether WooCommerce Deposits is active.
*
* @return bool
*/
private function is_woocommerce_deposits_active(): bool {
return defined( 'WC_DEPOSITS_VERSION' );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php
/**
* Detects whether a product has been customized by a first-party
* WooCommerce extension (product type change or plugin-specific meta).
*
* @package WooCommerce\PayPalCommerce\Compat\PluginDetector
*/

declare(strict_types=1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: apply code style rules to this file

Reason: The project style expects spaces around operators and parentheses

Sample:

declare( strict_types = 1 );

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thanks


namespace WooCommerce\PayPalCommerce\Compat\PluginDetector;

use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\Compat\Exception\PluginApiChangedException;

/**
* @see self::scan() for the plugins list.
*/
class ProductCustomizationDetector {

/**
* @var PluginDetector
*/
private PluginDetector $plugin_detector;

/**
* @var LoggerInterface
*/
private LoggerInterface $logger;

/**
* Cached result of $plugin_detector->scan(), since plugin activation
* cannot change within a request but scan() may be called once per product.
*
* @var array<string, bool>|null
*/
Comment on lines +24 to +29

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This @var annotation is good, it this docblock should stay

(just want to point out that this is different/useful in contrast to the comments above)

private ?array $active_plugins = null;

/**
* @param PluginDetector $plugin_detector The plugin presence detector.
* @param LoggerInterface $logger The logger.

@Narek13 Narek13 Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also suggest to remove these comments as they do not convey any info

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, they are useless. Removed.

*/
public function __construct( PluginDetector $plugin_detector, LoggerInterface $logger ) {
$this->plugin_detector = $plugin_detector;
$this->logger = $logger;
}

/**
* @param \WC_Product $product The product to check.
* @return array<string, bool> List of plugins check was made for,
* boolean shows whether the plugin has customized the product.
*/
public function scan( \WC_Product $product ): array {
if ( null === $this->active_plugins ) {
$this->active_plugins = $this->plugin_detector->scan();
}

$checks = array(
'woocommerce-subscriptions' => array( $this, 'is_customized_by_subscriptions' ),
'woocommerce-gift-cards' => array( $this, 'is_customized_by_gift_cards' ),
'woocommerce-product-bundles' => array( $this, 'is_customized_by_product_bundles' ),
'woocommerce-product-addons' => array( $this, 'is_customized_by_product_addons' ),
'woocommerce-min-max-quantities' => array( $this, 'is_customized_by_min_max_quantities' ),
'woocommerce-composite-products' => array( $this, 'is_customized_by_composite_products' ),
'woocommerce-shipping-per-product' => array( $this, 'is_customized_by_shipping_per_product' ),
'woocommerce-deposits' => array( $this, 'is_customized_by_deposits' ),
);

$result = array();
foreach ( $checks as $plugin => $check ) {
if ( empty( $this->active_plugins[ $plugin ] ) ) {
$result[ $plugin ] = false;
continue;
}

try {
// Only guards checks that call a plugin method (see assert_method_exists()).
// Checks that read meta directly (min/max quantities, per-product shipping)
// have no class/method to assert against, so they stay unprotected here.
$result[ $plugin ] = (bool) call_user_func( $check, $product );
} catch ( PluginApiChangedException $exception ) {
$this->logger->warning( "Product customization check for \"{$plugin}\" failed: " . $exception->getMessage() );
$result[ $plugin ] = false;
}
}

return $result;
}

/**
* Throws if the given plugin method does not exist, even though the
* plugin was detected as active. This points at the plugin having
* changed its API since this check was written.
*
* @param string $class The fully qualified class name.
* @param string $method The method name.
* @throws PluginApiChangedException If the class or method does not exist.
*/
private function assert_method_exists( string $class, string $method ): void {
if ( ! method_exists( $class, $method ) ) {
throw new PluginApiChangedException(
"{$class}::{$method}() does not exist even though the plugin was detected as active. Its API may have changed."
);
}
}

/**
* Checks whether the product is a WooCommerce Subscriptions product.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_subscriptions( \WC_Product $product ): bool {
$this->assert_method_exists( \WC_Subscriptions_Product::class, 'is_subscription' );

return \WC_Subscriptions_Product::is_subscription( $product );
}

/**
* Checks whether the product is a WooCommerce Gift Cards product.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_gift_cards( \WC_Product $product ): bool {
$this->assert_method_exists( \WC_GC_Gift_Card_Product::class, 'is_gift_card' );

return \WC_GC_Gift_Card_Product::is_gift_card( $product );
}

/**
* Checks whether the product is a WooCommerce Product Bundles product.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_product_bundles( \WC_Product $product ): bool {
return $product->is_type( 'bundle' );
}

/**
* Checks whether the product has its own WooCommerce Product Add-Ons configured.
*
* Calls WC_Product_Addons_Helper::get_product_addons() with $inc_parent and
* $inc_global set to false, which returns only this product's own
* `_product_addons` meta instead of merging in global/category-level addon
* groups that are not specific to this product.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_product_addons( \WC_Product $product ): bool {
$this->assert_method_exists( \WC_Product_Addons_Helper::class, 'get_product_addons' );

$addons = \WC_Product_Addons_Helper::get_product_addons( $product->get_id(), false, false, false );

return array() !== $addons;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Rewrite as count( $addons ) > 0 or ! empty( $addons )

Reason: makes the decision a bit easier to understand

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

/**
* Checks whether the product has its own WooCommerce Min/Max Quantities rules.
*
* Reads the product-level meta keys directly. The plugin itself has no
* product-only accessor for these two fields and reads the same meta keys
* directly internally; its only related method, get_group_of_quantity_for_product(),
* also merges in category-level term meta, which is not wanted here.
*
* Variations store these under differently named, `variation_`-prefixed
* meta keys instead of the plain ones used for simple/parent products.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_min_max_quantities( \WC_Product $product ): bool {
$meta_keys = $product instanceof \WC_Product_Variation
? array( 'variation_minimum_allowed_quantity', 'variation_maximum_allowed_quantity', 'variation_group_of_quantity' )
: array( 'minimum_allowed_quantity', 'maximum_allowed_quantity', 'group_of_quantity' );

foreach ( $meta_keys as $meta_key ) {
if ( '' !== $product->get_meta( $meta_key, true ) ) {
return true;
}
}

return false;
}

/**
* Checks whether the product is a WooCommerce Composite Products product.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_composite_products( \WC_Product $product ): bool {
return $product->is_type( 'composite' );
}

/**
* Checks whether the product has WooCommerce Per-Product Shipping enabled.
*
* Reads the `_per_product_shipping` meta directly. The plugin has no
* product-only accessor either: its own global function
* woocommerce_per_product_shipping_get_matching_rule() checks this same
* meta key internally, but requires a shipping package/destination
* context, so it cannot be used as a simple per-product boolean check.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_shipping_per_product( \WC_Product $product ): bool {
return 'yes' === $product->get_meta( '_per_product_shipping', true );
}

/**
* Checks whether the product has WooCommerce Deposits enabled.
*
* @param \WC_Product $product The product to check.
* @return bool
*/
private function is_customized_by_deposits( \WC_Product $product ): bool {
$this->assert_method_exists( \WC_Deposits_Product_Manager::class, 'deposits_enabled' );

return \WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() );
}
}
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ parameters:
- stubs/wpcli.php
- stubs/wc-blueprint.php
- stubs/abilities.php
- stubs/product-customizations.php
- vendor/inpsyde/wp-stubs-versions/latest.php
- vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php
paths:
Expand Down
Loading
Loading