diff --git a/docs/guides/integration.md b/docs/guides/integration.md index 1633b84e..fda51418 100644 --- a/docs/guides/integration.md +++ b/docs/guides/integration.md @@ -291,6 +291,8 @@ See [Section 2](#2-bundling-a-license-key). Bundling a key is done entirely thro | Function | Signature | Purpose | | ---------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `lw_harbor_is_product_license_active` | `(string $slug): bool` | Check if a specific product slug has an active license. | +| `lw_harbor_is_capability_license_available` | `(string $slug): bool` | Check if the unified key includes a capability, regardless of domain activation. | +| `lw_harbor_is_capability_license_active` | `(string $slug): bool` | Check if a capability is valid and activated on the current domain. | | `lw_harbor_has_unified_license_key` | `(): bool` | Check if a unified key is stored locally (no remote call). | | `lw_harbor_get_unified_license_key` | `(): ?string` | Retrieve the stored unified license key. | | `lw_harbor_is_feature_enabled` | `(string $slug): bool` | Check if a feature is currently active locally on this site. | diff --git a/src/Harbor/API/Functions/Global_Function_Registry.php b/src/Harbor/API/Functions/Global_Function_Registry.php index c95af2c0..e31b99ec 100644 --- a/src/Harbor/API/Functions/Global_Function_Registry.php +++ b/src/Harbor/API/Functions/Global_Function_Registry.php @@ -79,6 +79,34 @@ static function ( string $product ): bool { } ); + \_lw_harbor_global_function_registry( + 'lw_harbor_is_capability_license_available', + $version, + static function ( string $capability_slug ): bool { + try { + return Config::get_container()->get( License_Repository::class )->has_capability( $capability_slug ); + } catch ( Throwable $e ) { + self::debug_log_throwable( $e, 'Error checking capability license' ); + + return false; + } + } + ); + + \_lw_harbor_global_function_registry( + 'lw_harbor_is_capability_license_active', + $version, + static function ( string $capability_slug ): bool { + try { + return Config::get_container()->get( License_Repository::class )->is_capability_active( $capability_slug ); + } catch ( Throwable $e ) { + self::debug_log_throwable( $e, 'Error checking active capability license' ); + + return false; + } + } + ); + \_lw_harbor_global_function_registry( 'lw_harbor_is_feature_enabled', $version, diff --git a/src/Harbor/Licensing/Repositories/License_Repository.php b/src/Harbor/Licensing/Repositories/License_Repository.php index 8df542bc..12953684 100644 --- a/src/Harbor/Licensing/Repositories/License_Repository.php +++ b/src/Harbor/Licensing/Repositories/License_Repository.php @@ -2,6 +2,7 @@ namespace LiquidWeb\Harbor\Licensing\Repositories; +use LiquidWeb\Harbor\Licensing\Enums\Validation_Status; use LiquidWeb\Harbor\Licensing\Product_Collection; use LiquidWeb\Harbor\Licensing\Validation_State; use LiquidWeb\Harbor\Utils\Sanitize; @@ -458,6 +459,70 @@ public function is_product_valid( string $slug ): bool { return false; } + /** + * Whether the unified license includes a capability, regardless of domain activation. + * + * Returns true when at least one cached product entry lists the capability and + * the entry is not in a status that indicates the license does not cover it + * (e.g. expired, no entitlement). + * + * @since TBD + * + * @param string $capability_slug The capability slug to check. + * + * @return bool + */ + public function has_capability( string $capability_slug ): bool { + $products = $this->get_products(); + + if ( ! $products instanceof Product_Collection ) { + return false; + } + + foreach ( $products as $entry ) { + if ( + $this->is_capability_covered_by_license( $entry->get_validation_status() ) + && in_array( $capability_slug, $entry->get_capabilities(), true ) + ) { + return true; + } + } + + return false; + } + + /** + * Whether a capability is active on the current domain under the unified license. + * + * Returns true when at least one cached product entry is valid, activated on + * this domain, and lists the capability. + * + * @since TBD + * + * @param string $capability_slug The capability slug to check. + * + * @return bool + */ + public function is_capability_active( string $capability_slug ): bool { + $products = $this->get_products(); + + if ( ! $products instanceof Product_Collection ) { + return false; + } + + foreach ( $products as $entry ) { + if ( + $entry->is_valid() + && $entry->get_activated_here() + && in_array( $capability_slug, $entry->get_capabilities(), true ) + ) { + return true; + } + } + + return false; + } + /** * Whether a product has a valid, active license. * @@ -559,6 +624,37 @@ private function is_in_grace_period( string $slug ): bool { return $current_time <= $last_active + $this->get_grace_period_in_seconds(); } + /** + * Whether a validation status indicates the license covers the product. + * + * Excludes statuses where the unified key does not entitle the product + * (expired, suspended, no entitlement, etc.). Includes not_activated and + * similar states where the product is on the license but not active here. + * + * @since TBD + * + * @param string|null $status A Validation_Status constant value, or null. + * + * @return bool + */ + private function is_capability_covered_by_license( ?string $status ): bool { + if ( $status === null ) { + return false; + } + + $not_covered = [ + Validation_Status::EXPIRED, + Validation_Status::SUSPENDED, + Validation_Status::CANCELLED, + Validation_Status::LICENSE_SUSPENDED, + Validation_Status::LICENSE_BANNED, + Validation_Status::NO_ENTITLEMENT, + Validation_Status::INVALID_KEY, + ]; + + return ! in_array( $status, $not_covered, true ); + } + /** * Get the cached WP_Error from the most recent failed validation attempt * for the given license key if it is still within the supplied TTL. diff --git a/src/Harbor/global-functions.php b/src/Harbor/global-functions.php index 60b254fb..b05dec22 100644 --- a/src/Harbor/global-functions.php +++ b/src/Harbor/global-functions.php @@ -263,6 +263,47 @@ function lw_harbor_refresh_catalog(): bool { } } +if ( ! function_exists( 'lw_harbor_is_capability_license_available' ) ) { + /** + * Whether the unified license includes a capability, regardless of domain activation. + * + * Does not make any remote API calls — only checks the locally cached + * product catalog. + * + * @since TBD + * + * @param string $capability_slug The capability slug to check. + * + * @return bool + */ + function lw_harbor_is_capability_license_available( string $capability_slug ): bool { + $callback = _lw_harbor_global_function_registry( 'lw_harbor_is_capability_license_available' ); + + return $callback ? (bool) $callback( $capability_slug ) : false; + } +} + +if ( ! function_exists( 'lw_harbor_is_capability_license_active' ) ) { + /** + * Whether a capability is active on the current domain under the unified license. + * + * Returns true when the capability is covered by the unified key and the + * product is valid and activated on this domain. Does not make any remote + * API calls — only checks the locally cached product catalog. + * + * @since TBD + * + * @param string $capability_slug The capability slug to check. + * + * @return bool + */ + function lw_harbor_is_capability_license_active( string $capability_slug ): bool { + $callback = _lw_harbor_global_function_registry( 'lw_harbor_is_capability_license_active' ); + + return $callback ? (bool) $callback( $capability_slug ) : false; + } +} + if ( ! function_exists( 'lw_harbor_display_legacy_license_page_notice' ) ) { /** * Displays an informational admin notice on legacy plugin license pages. diff --git a/tests/wpunit/API/Functions/GlobalFunctionsTest.php b/tests/wpunit/API/Functions/GlobalFunctionsTest.php index 4d30b8e3..a35377fd 100644 --- a/tests/wpunit/API/Functions/GlobalFunctionsTest.php +++ b/tests/wpunit/API/Functions/GlobalFunctionsTest.php @@ -189,6 +189,171 @@ public function test_is_product_license_active_returns_false_for_unknown_product $this->assertFalse( lw_harbor_is_product_license_active( 'learndash' ) ); } + // ------------------------------------------------------------------------- + // lw_harbor_is_capability_license_available() + // ------------------------------------------------------------------------- + + public function test_is_capability_license_available_returns_false_without_cached_products(): void { + $this->assertFalse( lw_harbor_is_capability_license_available( 'give' ) ); + } + + public function test_is_capability_license_available_returns_true_for_valid_capability(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'valid', + 'activated_here' => true, + 'capabilities' => [ 'give', 'give-recurring' ], + ], + ] + ); + + $this->assertTrue( lw_harbor_is_capability_license_available( 'give-recurring' ) ); + } + + public function test_is_capability_license_available_returns_true_when_not_activated_on_domain(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'not_activated', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ], + ] + ); + + $this->assertTrue( lw_harbor_is_capability_license_available( 'give-recurring' ) ); + } + + public function test_is_capability_license_available_returns_false_for_expired_product(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2020-12-31 23:59:59', + 'validation_status' => 'expired', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ], + ] + ); + + $this->assertFalse( lw_harbor_is_capability_license_available( 'give-recurring' ) ); + } + + public function test_is_capability_license_available_returns_false_for_unknown_capability(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'valid', + 'activated_here' => true, + 'capabilities' => [ 'give' ], + ], + ] + ); + + $this->assertFalse( lw_harbor_is_capability_license_available( 'give-recurring' ) ); + } + + // ------------------------------------------------------------------------- + // lw_harbor_is_capability_license_active() + // ------------------------------------------------------------------------- + + public function test_is_capability_license_active_returns_false_without_cached_products(): void { + $this->assertFalse( lw_harbor_is_capability_license_active( 'give' ) ); + } + + public function test_is_capability_license_active_returns_true_for_valid_activated_capability(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'valid', + 'activated_here' => true, + 'capabilities' => [ 'give', 'give-recurring' ], + ], + ] + ); + + $this->assertTrue( lw_harbor_is_capability_license_active( 'give-recurring' ) ); + } + + public function test_is_capability_license_active_returns_false_when_not_activated_on_domain(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'not_activated', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ], + ] + ); + + $this->assertFalse( lw_harbor_is_capability_license_active( 'give-recurring' ) ); + } + + public function test_is_capability_license_active_returns_false_for_unknown_capability(): void { + $this->seed_capability_products( + [ + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'valid', + 'activated_here' => true, + 'capabilities' => [ 'give' ], + ], + ] + ); + + $this->assertFalse( lw_harbor_is_capability_license_active( 'give-recurring' ) ); + } + + /** + * @param array> $products + */ + private function seed_capability_products( array $products ): void { + $entries = array_map( + static function ( array $product ): Product_Entry { + return Product_Entry::from_array( $product ); + }, + $products + ); + + $collection = Product_Collection::from_array( $entries ); + + update_option( + License_Repository::PRODUCTS_STATE_OPTION_NAME, + [ + 'collection' => $collection->to_array(), + 'last_success_at' => null, + 'last_error' => null, + ] + ); + } + // ------------------------------------------------------------------------- // lw_harbor_is_feature_enabled() / lw_harbor_is_feature_available() // ------------------------------------------------------------------------- diff --git a/tests/wpunit/Licensing/Repositories/License_RepositoryTest.php b/tests/wpunit/Licensing/Repositories/License_RepositoryTest.php index 36eb25c2..ae7c4ad5 100644 --- a/tests/wpunit/Licensing/Repositories/License_RepositoryTest.php +++ b/tests/wpunit/Licensing/Repositories/License_RepositoryTest.php @@ -555,6 +555,98 @@ public function test_is_product_valid_returns_false_for_invalid_product(): void $this->assertFalse( $this->repository->is_product_valid( 'give' ) ); } + public function test_has_capability_returns_false_without_cached_products(): void { + $this->assertFalse( $this->repository->has_capability( 'give-recurring' ) ); + } + + public function test_has_capability_returns_true_for_not_activated_product(): void { + $this->repository->set_products( + Product_Collection::from_array( + [ + Product_Entry::from_array( + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'not_activated', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ] + ), + ] + ) + ); + + $this->assertTrue( $this->repository->has_capability( 'give-recurring' ) ); + } + + public function test_has_capability_returns_false_for_expired_product(): void { + $this->repository->set_products( + Product_Collection::from_array( + [ + Product_Entry::from_array( + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2020-12-31 23:59:59', + 'validation_status' => 'expired', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ] + ), + ] + ) + ); + + $this->assertFalse( $this->repository->has_capability( 'give-recurring' ) ); + } + + public function test_is_capability_active_returns_true_for_valid_activated_product(): void { + $this->repository->set_products( + Product_Collection::from_array( + [ + Product_Entry::from_array( + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'valid', + 'activated_here' => true, + 'capabilities' => [ 'give', 'give-recurring' ], + ] + ), + ] + ) + ); + + $this->assertTrue( $this->repository->is_capability_active( 'give-recurring' ) ); + } + + public function test_is_capability_active_returns_false_for_not_activated_product(): void { + $this->repository->set_products( + Product_Collection::from_array( + [ + Product_Entry::from_array( + [ + 'product_slug' => 'give', + 'tier' => 'pro', + 'status' => 'active', + 'expires' => '2030-12-31 23:59:59', + 'validation_status' => 'not_activated', + 'activated_here' => false, + 'capabilities' => [ 'give', 'give-recurring' ], + ] + ), + ] + ) + ); + + $this->assertFalse( $this->repository->is_capability_active( 'give-recurring' ) ); + } + public function test_store_fires_action_when_key_changes(): void { $fired = [];