From 447195fd0c86f4f9e058b0aff8ba2a844edc6438 Mon Sep 17 00:00:00 2001 From: Franco Risso Date: Tue, 6 May 2025 02:01:35 -0700 Subject: [PATCH 01/28] Create the RolloutSwitches feature to control new features going live (#3126) Summary: ## Description The rules are: **Client has feature flag A but feature not in server response:** We will return true, if the feature exists in the client means Meta added it and if it's not in the server, it means the feature is 100% rolled out. This means features we started to rollout but we decided to remove, will always return false from the server. The trade-off here is that we assume most features will succeed to rollout, so the amount of data we have to send from the backend like "feature-that-failed-rollout: false" is minimum, and we will keep track of the version in which we rollout each feature to deprecate those features in the backend once we have no traffic from those versions or we decide to deprecate support. **Client doesn't has flag A but server does**: We ignore such case as the client can only requests things it has access to **Client and Server have flag A**: We return the value of it with our plugin's lib ### Type of change Please delete options that are not relevant. - New feature (non-breaking change which adds functionality) ## Checklist - [] I have commented my code, particularly in hard-to-understand areas. - [] I have confirmed that my changes do not introduce any new PHPCS warnings or errors. - [] I have checked plugin debug logs that my changes do not introduce any new PHP warnings or FATAL errors. - [] I followed general Pull Request best practices. Meta employees to follow this [wiki]([url](https://fburl.com/wiki/2cgfduwc)). - [] I have added tests (if necessary) and all the new and existing unit tests pass locally with my changes. - [] I have completed dogfooding and QA testing, or I have conducted thorough due diligence to ensure that it does not break existing functionality. - [] I have updated or requested update to plugin documentations (if necessary). Meta employees to follow this [wiki]([url](https://fburl.com/wiki/nhx73tgs)). ## Changelog entry add support for rollout switches in the plugin to control feature rollouts from meta side. Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3126 Test Plan: I ran tests Screenshot 2025-05-01 at 15 24 23 Reviewed By: sharunaanandraj Differential Revision: D74057446 Pulled By: francorisso fbshipit-source-id: 8c6d4396ddbb65b1ee15e7a1bac1dc28d748eb54 --- class-wc-facebookcommerce.php | 14 ++ includes/API.php | 23 ++++ includes/API/FBE/RolloutSwitches/Request.php | 20 +++ includes/API/FBE/RolloutSwitches/Response.php | 23 ++++ includes/RolloutSwitches.php | 83 +++++++++++ tests/Unit/RolloutSwitchesTest.php | 129 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 includes/API/FBE/RolloutSwitches/Request.php create mode 100644 includes/API/FBE/RolloutSwitches/Response.php create mode 100644 includes/RolloutSwitches.php create mode 100644 tests/Unit/RolloutSwitchesTest.php diff --git a/class-wc-facebookcommerce.php b/class-wc-facebookcommerce.php index 119b63133..56bd4b208 100644 --- a/class-wc-facebookcommerce.php +++ b/class-wc-facebookcommerce.php @@ -111,6 +111,9 @@ class WC_Facebookcommerce extends WooCommerce\Facebook\Framework\Plugin { /** @var WooCommerce\Facebook\Products\FBCategories instance. */ private $fb_categories; + /** @var WooCommerce\Facebook\RolloutSwitches instance. */ + private $rollout_switches; + /** * The Debug tools instance. * @@ -214,6 +217,7 @@ public function init() { $this->connection_handler = new WooCommerce\Facebook\Handlers\Connection( $this ); $this->webhook_handler = new WooCommerce\Facebook\Handlers\WebHook( $this ); $this->tracker = new WooCommerce\Facebook\Utilities\Tracker(); + $this->rollout_switches = new WooCommerce\Facebook\RolloutSwitches( $this ); // Init jobs $this->job_manager = new WooCommerce\Facebook\Jobs\JobManager(); @@ -245,6 +249,7 @@ function () { }, 0 ); + add_action( 'admin_init', [ $this->rollout_switches, 'init' ] ); } /** @@ -770,6 +775,15 @@ public function get_asset_build_dir_url() { return $this->get_plugin_url() . '/assets/build'; } + /** + * Gets the connection handler. + * + * @return WooCommerce\Facebook\RolloutSwitches + */ + public function get_rollout_switches() { + return $this->rollout_switches; + } + /** Conditional methods ***************************************************************************************/ diff --git a/includes/API.php b/includes/API.php index cc8d4dc94..f44aaf992 100644 --- a/includes/API.php +++ b/includes/API.php @@ -278,6 +278,29 @@ public function get_business_configuration( $external_business_id ) { return $this->perform_request( $request ); } + /** + * Gets rollout switches + * + * @param string $external_business_id + * @return API\FBE\RolloutSwitches\Response + * @throws ApiException + */ + public function get_rollout_switches( string $external_business_id ) { + if(!$this->get_access_token()) { + return null; + } + + $request = new API\FBE\RolloutSwitches\Request( $external_business_id ); + $request->set_params( + array( + 'access_token' => $this->get_access_token(), + 'fbe_external_business_id'=> $external_business_id + ) + ); + $this->set_response_handler( API\FBE\RolloutSwitches\Response::class ); + return $this->perform_request( $request ); + } + /** * Updates the plugin version configuration. * diff --git a/includes/API/FBE/RolloutSwitches/Request.php b/includes/API/FBE/RolloutSwitches/Request.php new file mode 100644 index 000000000..be72f2391 --- /dev/null +++ b/includes/API/FBE/RolloutSwitches/Request.php @@ -0,0 +1,20 @@ +response_data['data'] ?? []; + } +} diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php new file mode 100644 index 000000000..8f4ff9c37 --- /dev/null +++ b/includes/RolloutSwitches.php @@ -0,0 +1,83 @@ +plugin = $plugin; + add_action( Heartbeat::HOURLY, array( $this, 'init' ) ); + } + + public function init() { + $swiches = $this->plugin->get_api()->get_rollout_switches( + $this->plugin->get_connection_handler()->get_external_business_id() + ); + + $data = $swiches->get_data(); + foreach ( $data as $switch ) { + if ( ! isset( $switch['switch'] ) || ! $this->is_switch_active( $switch['switch'] ) ) { + continue; + } + $this->rollout_switches[ $switch['switch'] ] = (bool) $switch['enabled']; + } + } + + /** + * Get if the switch is enabled or not. + * If the switch is not active -> + * FALSE + * + * If the switch is active but not in the response -> + * TRUE: we assume this is an old version of the plugin + * and the backend since has changed and the switch was released + * in the backend we will otherwise always return false for unreleased + * features + * + * If the feature is active and in the response -> + * we will return the value of the switch from the response + * + * @param string $switch_name The name of the switch. + */ + public function is_switch_enabled( string $switch_name ) { + if ( ! $this->is_switch_active( $switch_name ) ) { + return false; + } + + return $this->rollout_switches[ $switch_name ] ?? true; + } + + public function is_switch_active( string $switch_name ) { + return in_array( $switch_name, self::ACTIVE_SWITCHES, true ); + } +} diff --git a/tests/Unit/RolloutSwitchesTest.php b/tests/Unit/RolloutSwitchesTest.php new file mode 100644 index 000000000..0fc140604 --- /dev/null +++ b/tests/Unit/RolloutSwitchesTest.php @@ -0,0 +1,129 @@ +api = new Api( $this->access_token ); + } + + public function test_api() { + $response = function( $result, $parsed_args, $url ) { + $this->assertEquals( 'GET', $parsed_args['method'] ); + $url_params = "access_token={$this->access_token}&fbe_external_business_id={$this->external_business_id}"; + $path = "fbe_business/fbe_rollout_switches"; + $this->assertEquals( "{$this->endpoint}{$this->version}/{$path}?{$url_params}", $url ); + return [ + 'body' => '{"data":[{"switch": "switch_a","enabled":"1"}, {"switch": "switch_b","enabled":""}, {"switch": "switch_c","enabled":"1"}]}', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); + + $response = $this->api->get_rollout_switches( $this->external_business_id ); + $this->assertEquals([ + [ + 'switch' => 'switch_a', + 'enabled' => '1', + ], + [ + 'switch' => 'switch_b', + 'enabled' => '', + ], + [ + 'switch' => 'switch_c', + 'enabled' => '1', + ], + ], $response->get_data()); + } + + public function test_plugin() { + + // mock the active filters to test business values + $plugin = facebook_for_woocommerce(); + $plugin_ref_obj = new ReflectionObject( $plugin ); + // setup connection handler + $prop_connection_handler = $plugin_ref_obj->getProperty( 'connection_handler' ); + $prop_connection_handler->setAccessible( true ); + $mock_connection_handler = $this->getMockBuilder( Connection::class ) + ->disableOriginalConstructor() + ->setMethods( array( 'get_external_business_id', 'is_connected', 'get_access_token' ) ) + ->getMock(); + $mock_connection_handler->expects( $this->any() )->method( 'get_external_business_id' )->willReturn( $this->external_business_id ); + $mock_connection_handler->expects( $this->any() )->method( 'get_access_token' )->willReturn( $this->access_token ); + $prop_connection_handler->setValue( $plugin, $mock_connection_handler ); + // setup API + $prop_api = $plugin_ref_obj->getProperty( 'api' ); + $prop_api->setAccessible( true ); + $mock_api = $this->getMockBuilder( API::class )->disableOriginalConstructor()->setMethods( array( 'do_remote_request' ) )->getMock(); + $mock_api->expects( $this->any() )->method( 'do_remote_request' )->willReturn( + array('body' => json_encode(array( + 'data' => array( + array('switch' => 'switch_a','enabled' => '1'), + array('switch' => 'switch_b', 'enabled' => ''), + array( 'switch' => 'switch_c', 'enabled' => '1'), + ) + )))); + $prop_api->setValue( $plugin, $mock_api ); + + $switch_mock = $this->getMockBuilder(RolloutSwitches::class) + ->setConstructorArgs( array( $plugin ) ) + ->onlyMethods(['is_switch_active']) + ->getMock(); + $switch_mock->expects($this->any())->method('is_switch_active') + ->willReturnCallback(function($switch_name) { + switch ($switch_name) { + case 'switch_a': + case 'switch_b': + case 'switch_d': + return true; + default: + return false; + } + }); + $switch_mock->init(); + + // If the switch is not active -> FALSE (independent of the response being true) + $this->assertEquals( $switch_mock->is_switch_enabled("switch_c"), false ); + + // If the feature is active and in the response -> response value + $this->assertEquals( $switch_mock->is_switch_enabled("switch_a"), true ); + $this->assertEquals( $switch_mock->is_switch_enabled("switch_b"), false ); + + // If the switch is active but not in the response -> TRUE + $this->assertEquals( $switch_mock->is_switch_enabled("switch_d"), true ); + } +} From 30c7aa92f2c1ea218b298a34b5ba5480b11b79c6 Mon Sep 17 00:00:00 2001 From: Franco Risso Date: Tue, 6 May 2025 11:00:30 -0700 Subject: [PATCH 02/28] Fix tests broken by the RolloutSwitches introduction after rebasing. (#3146) Summary: ## Description Fix tests broken by the RolloutSwitches introduction after rebasing. ### Type of change Please delete options that are not relevant. - Bug fix (non-breaking change which fixes an issue) ## Checklist - [] I have commented my code, particularly in hard-to-understand areas. - [] I have confirmed that my changes do not introduce any new PHPCS warnings or errors. - [] I have checked plugin debug logs that my changes do not introduce any new PHP warnings or FATAL errors. - [] I followed general Pull Request best practices. Meta employees to follow this [wiki]([url](https://fburl.com/wiki/2cgfduwc)). - [] I have added tests (if necessary) and all the new and existing unit tests pass locally with my changes. - [] I have completed dogfooding and QA testing, or I have conducted thorough due diligence to ensure that it does not break existing functionality. - [] I have updated or requested update to plugin documentations (if necessary). Meta employees to follow this [wiki]([url](https://fburl.com/wiki/nhx73tgs)). ## Changelog entry fix tests in the rollout switches file Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3146 Test Plan: Run tests ## Screenshots ### After Screenshot 2025-05-06 at 17 16 28 Reviewed By: nrostrow-meta, devbodaghe Differential Revision: D74254884 Pulled By: francorisso fbshipit-source-id: 8f1264a7f894155909cc7ece5499e51bc9e2ff79 --- includes/RolloutSwitches.php | 12 +++++++----- tests/Unit/RolloutSwitchesTest.php | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php index 8f4ff9c37..b43eed577 100644 --- a/includes/RolloutSwitches.php +++ b/includes/RolloutSwitches.php @@ -34,17 +34,19 @@ class RolloutSwitches { */ private $rollout_switches = array(); - public function __construct( \WC_Facebookcommerce $plugin ) { + public function __construct( \WC_Facebookcommerce $plugin ) { $this->plugin = $plugin; add_action( Heartbeat::HOURLY, array( $this, 'init' ) ); } public function init() { - $swiches = $this->plugin->get_api()->get_rollout_switches( - $this->plugin->get_connection_handler()->get_external_business_id() - ); + $external_business_id = $this->plugin->get_connection_handler()->get_external_business_id(); + if ( empty( $external_business_id ) ) { + return; + } - $data = $swiches->get_data(); + $swiches = $this->plugin->get_api()->get_rollout_switches( $external_business_id ); + $data = $swiches->get_data(); foreach ( $data as $switch ) { if ( ! isset( $switch['switch'] ) || ! $this->is_switch_active( $switch['switch'] ) ) { continue; diff --git a/tests/Unit/RolloutSwitchesTest.php b/tests/Unit/RolloutSwitchesTest.php index 0fc140604..5bfa108d6 100644 --- a/tests/Unit/RolloutSwitchesTest.php +++ b/tests/Unit/RolloutSwitchesTest.php @@ -5,7 +5,7 @@ use WooCommerce\Facebook\Framework\Api\Exception as ApiException; use WooCommerce\Facebook\RolloutSwitches; -class RolloutSwitchesTest extends \WooCommerce\Facebook\Tests\Unit\AbstractWPUnitTestWithSafeFiltering { +class RolloutSwitchesTest extends \WooCommerce\Facebook\Tests\AbstractWPUnitTestWithSafeFiltering { /** * Facebook Graph API endpoint. From 8d40076c51703599ea31cacbd079f594421537e6 Mon Sep 17 00:00:00 2001 From: Carter Buce Date: Thu, 8 May 2025 09:57:46 -0700 Subject: [PATCH 03/28] Fix RolloutSwitches Init (#3157) Summary: ## Description I am running into the following exception message when my shop is in a disconnected state: image This is because upon disconnection, the external_business_id is not reset. We should be checking is_connected() at the start of the init function rather than existence of the EBID ### Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [] I have commented my code, particularly in hard-to-understand areas. - [] I have confirmed that my changes do not introduce any new PHPCS warnings or errors. - [] I have checked plugin debug logs that my changes do not introduce any new PHP warnings or FATAL errors. - [] I followed general Pull Request best practices. Meta employees to follow this [wiki]([url](https://fburl.com/wiki/2cgfduwc)). - [] I have added tests (if necessary) and all the new and existing unit tests pass locally with my changes. - [] I have completed dogfooding and QA testing, or I have conducted thorough due diligence to ensure that it does not break existing functionality. - [] I have updated or requested update to plugin documentations (if necessary). Meta employees to follow this [wiki]([url](https://fburl.com/wiki/nhx73tgs)). ## Changelog entry Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3157 Test Plan: Verified extension tab now loads properly image Reviewed By: ajello-meta, nealweiMeta, nrostrow-meta Differential Revision: D74344368 Pulled By: carterbuce fbshipit-source-id: 2deeaf1a7c8911e773413a6bb14285a4bf5f9cff --- includes/RolloutSwitches.php | 34 ++++++++++++++++++++---------- tests/Unit/RolloutSwitchesTest.php | 17 ++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php index b43eed577..e9beb84dc 100644 --- a/includes/RolloutSwitches.php +++ b/includes/RolloutSwitches.php @@ -10,6 +10,7 @@ namespace WooCommerce\Facebook; +use WooCommerce\Facebook\Framework\Api\Exception; use WooCommerce\Facebook\Utilities\Heartbeat; defined( 'ABSPATH' ) || exit; @@ -19,7 +20,7 @@ * features in the Facebook for WooCommerce plugin. */ class RolloutSwitches { - /** @var WooCommerce\Facebook\Commerce commerce handler */ + /** @var \WC_Facebookcommerce commerce handler */ private \WC_Facebookcommerce $plugin; public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; @@ -32,7 +33,7 @@ class RolloutSwitches { * * @var array */ - private $rollout_switches = array(); + private array $rollout_switches = array(); public function __construct( \WC_Facebookcommerce $plugin ) { $this->plugin = $plugin; @@ -40,18 +41,29 @@ public function __construct( \WC_Facebookcommerce $plugin ) { } public function init() { - $external_business_id = $this->plugin->get_connection_handler()->get_external_business_id(); - if ( empty( $external_business_id ) ) { + $is_connected = $this->plugin->get_connection_handler()->is_connected(); + if ( ! $is_connected ) { return; } - $swiches = $this->plugin->get_api()->get_rollout_switches( $external_business_id ); - $data = $swiches->get_data(); - foreach ( $data as $switch ) { - if ( ! isset( $switch['switch'] ) || ! $this->is_switch_active( $switch['switch'] ) ) { - continue; + try { + $external_business_id = $this->plugin->get_connection_handler()->get_external_business_id(); + $switches = $this->plugin->get_api()->get_rollout_switches( $external_business_id ); + $data = $switches->get_data(); + foreach ( $data as $switch ) { + if ( ! isset( $switch['switch'] ) || ! $this->is_switch_active( $switch['switch'] ) ) { + continue; + } + $this->rollout_switches[ $switch['switch'] ] = (bool) $switch['enabled']; } - $this->rollout_switches[ $switch['switch'] ] = (bool) $switch['enabled']; + } catch ( Exception $e ) { + \WC_Facebookcommerce_Utils::log_exception_immediately_to_meta( + $e, + [ + 'event' => 'rollout_switches', + 'event_type' => 'init', + ] + ); } } @@ -79,7 +91,7 @@ public function is_switch_enabled( string $switch_name ) { return $this->rollout_switches[ $switch_name ] ?? true; } - public function is_switch_active( string $switch_name ) { + public function is_switch_active( string $switch_name ): bool { return in_array( $switch_name, self::ACTIVE_SWITCHES, true ); } } diff --git a/tests/Unit/RolloutSwitchesTest.php b/tests/Unit/RolloutSwitchesTest.php index 5bfa108d6..4e7953a86 100644 --- a/tests/Unit/RolloutSwitchesTest.php +++ b/tests/Unit/RolloutSwitchesTest.php @@ -20,7 +20,7 @@ class RolloutSwitchesTest extends \WooCommerce\Facebook\Tests\AbstractWPUnitTest * @var string */ private $version = Api::API_VERSION; - + /** * @var Api */ @@ -36,8 +36,8 @@ public function setUp(): void { parent::setUp(); $this->api = new Api( $this->access_token ); } - - public function test_api() { + + public function test_api() { $response = function( $result, $parsed_args, $url ) { $this->assertEquals( 'GET', $parsed_args['method'] ); $url_params = "access_token={$this->access_token}&fbe_external_business_id={$this->external_business_id}"; @@ -52,7 +52,7 @@ public function test_api() { ]; }; $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); - + $response = $this->api->get_rollout_switches( $this->external_business_id ); $this->assertEquals([ [ @@ -70,8 +70,8 @@ public function test_api() { ], $response->get_data()); } - public function test_plugin() { - + public function test_plugin() { + // mock the active filters to test business values $plugin = facebook_for_woocommerce(); $plugin_ref_obj = new ReflectionObject( $plugin ); @@ -84,6 +84,7 @@ public function test_plugin() { ->getMock(); $mock_connection_handler->expects( $this->any() )->method( 'get_external_business_id' )->willReturn( $this->external_business_id ); $mock_connection_handler->expects( $this->any() )->method( 'get_access_token' )->willReturn( $this->access_token ); + $mock_connection_handler->expects( $this->any() )->method( 'is_connected' )->willReturn( true ); $prop_connection_handler->setValue( $plugin, $mock_connection_handler ); // setup API $prop_api = $plugin_ref_obj->getProperty( 'api' ); @@ -98,7 +99,7 @@ public function test_plugin() { ) )))); $prop_api->setValue( $plugin, $mock_api ); - + $switch_mock = $this->getMockBuilder(RolloutSwitches::class) ->setConstructorArgs( array( $plugin ) ) ->onlyMethods(['is_switch_active']) @@ -122,7 +123,7 @@ public function test_plugin() { // If the feature is active and in the response -> response value $this->assertEquals( $switch_mock->is_switch_enabled("switch_a"), true ); $this->assertEquals( $switch_mock->is_switch_enabled("switch_b"), false ); - + // If the switch is active but not in the response -> TRUE $this->assertEquals( $switch_mock->is_switch_enabled("switch_d"), true ); } From cb687d21e6e307392501e1f172389939f643c486 Mon Sep 17 00:00:00 2001 From: sharunaanandraj Date: Thu, 8 May 2025 15:02:28 -0700 Subject: [PATCH 04/28] Integrate Whatsapp Utility Messaging for WooCommerce Order Update Notifications (#3164) Summary: We are integrating Whatsapp Utility Messaging on the facebook for woocommerce plugin. We have been building the integration on a dev branch as aligned and getting all PRs approved by vahidkay-meta / iodic . This PR merges all the development changes to main branch to make it easy to cherry pick commits for the release. We are targeting release on (05/12). - New feature (non-breaking change which adds functionality) - [yes] I have commented my code, particularly in hard-to-understand areas. - [yes] I have confirmed that my changes do not introduce any new PHPCS warnings or errors. - [yes] I have checked plugin debug logs that my changes do not introduce any new PHP warnings or FATAL errors. - [yes] I followed general Pull Request best practices. Meta employees to follow this [wiki]([url](https://fburl.com/wiki/2cgfduwc)). - [in progress] I have added tests (if necessary) and all the new and existing unit tests pass locally with my changes. - [in progress] I have completed dogfooding and QA testing, or I have conducted thorough due diligence to ensure that it does not break existing functionality. - [n/a] I have updated or requested update to plugin documentations (if necessary). Meta employees to follow this [wiki]([url](https://fburl.com/wiki/nhx73tgs)). Integrate Whatsapp Utility Messaging for WooCommerce Order Update Notifications Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3164 Test Plan: Overview of WooCommerce flow https://github.com/user-attachments/assets/011012db-3378-4fdc-89b2-4d41be683928 Example of Order placed whatsapp notification sent to user when event is enabled. Screenshot 2025-05-07 at 10 55 56 PM Reviewed By: woo-ardsouza, vahidkay-meta Differential Revision: D74376980 Pulled By: sharunaanandraj fbshipit-source-id: c2915dccb19e006f6d326be41c9f71a2f1dbf161 --- ...ebook-for-woocommerce-whatsapp-utility.css | 249 ++++++ assets/images/whatsapp_icon.png | Bin 0 -> 79502 bytes assets/js/admin/whatsapp-billing.js | 50 ++ assets/js/admin/whatsapp-connection.js | 73 ++ assets/js/admin/whatsapp-consent-remove.js | 94 +++ assets/js/admin/whatsapp-consent.js | 83 ++ assets/js/admin/whatsapp-disconnect.js | 75 ++ assets/js/admin/whatsapp-events.js | 129 ++++ assets/js/admin/whatsapp-finish.js | 53 ++ assets/js/admin/whatsapp-templates.js | 26 + class-wc-facebookcommerce.php | 36 +- facebook-commerce-whatsapp-utility-event.php | 131 ++++ facebook-commerce.php | 21 + includes/AJAX.php | 260 +++++++ includes/Admin/Settings.php | 45 +- .../Settings_Screens/Whatsapp_Utility.php | 716 ++++++++++++++++++ .../Handlers/WhatsAppUtilityConnection.php | 467 ++++++++++++ includes/Handlers/Whatsapp_Webhook.php | 209 +++++ includes/RolloutSwitches.php | 6 +- webpack.config.js | 30 +- 20 files changed, 2729 insertions(+), 24 deletions(-) create mode 100644 assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css create mode 100644 assets/images/whatsapp_icon.png create mode 100644 assets/js/admin/whatsapp-billing.js create mode 100644 assets/js/admin/whatsapp-connection.js create mode 100644 assets/js/admin/whatsapp-consent-remove.js create mode 100644 assets/js/admin/whatsapp-consent.js create mode 100644 assets/js/admin/whatsapp-disconnect.js create mode 100644 assets/js/admin/whatsapp-events.js create mode 100644 assets/js/admin/whatsapp-finish.js create mode 100644 assets/js/admin/whatsapp-templates.js create mode 100644 facebook-commerce-whatsapp-utility-event.php create mode 100644 includes/Admin/Settings_Screens/Whatsapp_Utility.php create mode 100644 includes/Handlers/WhatsAppUtilityConnection.php create mode 100644 includes/Handlers/Whatsapp_Webhook.php diff --git a/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css b/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css new file mode 100644 index 000000000..76fa2dea5 --- /dev/null +++ b/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css @@ -0,0 +1,249 @@ +.onboarding-card { + background-color: #f7f7f7; + border: 1px solid #ccc; + border-radius: 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + width: 680px; + margin: 40px auto 0; /* Top margin 5px, horizontal centering */ +} +.custom-dashicon-check { + position: relative; + display: inline-block; + width: 26px; /* Set the size of the circle */ + height: 26px; /* Set the size of the circle */ + background-color: #1a805b; /* Fill the circle with green */ + border-radius: 50%; /* Make it a circle */ + margin-right: 20px; + top: 50%; + transform: translateY(-50%); +} +.custom-dashicon-check::before { + content: '\f147'; /* Unicode for dashicons-yes-alt */ + font-family: 'Dashicons'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-55%, -45%) scale(1.2); /* Center and slightly enlarge the checkmark */ + font-size: 20px; /* Set the size of the checkmark */ + color: white; /* Make the checkmark white */ + text-shadow: + -4px 0 #1a805b, + 4px 0 #1a805b, + 0 -2px #1a805b, + 0 2px #1a805b; /* Increase shadow offsets to thin the checkmark more */ +} + +.custom-dashicon-circle { + position: relative; + display: inline-block; + width: 20px; /* Set the size of the circle */ + height: 20px; /* Set the size of the circle */ + border-radius: 50%; /* Make it a circle */ + margin-right: 20px; + border: 3px solid #222121ab; + top: 50%; + transform: translateY(-50%); +} +.custom-dashicon-halfcircle { + position: relative; + display: inline-block; + width: 20px; /* Set the size of the circle */ + height: 20px; /* Set the size of the circle */ + border-radius: 50%; /* Make it a circle */ + margin-right: 20px; + border: 3px solid #222121ab; + background-image: linear-gradient(to left, #222121ab 50%, transparent 50%); + background-clip: padding-box; /* Add this line */ + top: 50%; + transform: translateY(-50%); +} +.card-content-icon { + display: flex; +} +.card-item { + padding: 10px 24px; + justify-content: space-between; + display: flex; +} +.divider { + border-bottom: 1px solid #ccc; +} +.review-payment-content { + padding: 20px; + margin-bottom: 10px; +} +.whatsapp-onboarding-button { + margin-left: auto; + position: relative; + top: 50%; + margin: auto 0; /* Ensure button is centered */ +} +.whatsapp-onboarding-done-button { + margin-left: auto; + padding: 6px 0; +} +.card-content { + max-width: 90%; +} +.card-content-icon h2 { + top: 50%; +} +.card-content-icon p { + margin-top: -10px; /* Remove margin top */ +} +.event-config { + display: flex; + flex-direction: row; + padding-top: 10px; +} +.event-config-heading-container { + display: flex; + flex-direction: row; +} +.event-config-manage-button { + position: relative; + margin-left: auto; + top: 50%; + padding-left: 20px; + padding-right: 10px; +} +.event-config-status { + background-color: #FFFFFF; + border: 1px solid #9f9f9f; + color: #9f9f9f; + padding: 4px 10px; + text-align: center; + display: inline-block; + border-radius: 16px; + margin-left: 10px; + font-size: small; + align-self: center; +} +.on-status { + background-color: #00A32A; + color: #FFFFFF; + border:none; +} +.manage-event-card-item { + padding: 20px; + justify-content: space-between; +} +.manage-event-selector { + min-width: 100%; +} +.manage-event-template-block { + border: 1px solid #c4c3c3; + margin-bottom: 20px; +} +.manage-event-template-header { + position: relative; + display: block; + padding: 20px; + font-size: medium; +} +.manage-event-template-footer { + padding: 20px; + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; +} +.manage-event-button { + margin-left: 20px; +} +.fbwa-hidden-element { + display: none; +} +.error-notice-wrapper { + justify-content: left; + padding-left: 20px; + padding-bottom: 10px; + margin-right: 20px; /* Add the right margin */ +} +.notice-error { + background-color: #f7f7f7; + border: 1px solid #EF0000; /* Red border */ + border-radius: 0; /* No curvature */ + border-left-width: 5px; /* Thicker left border */ + width: 100%; /* Take up full width */ +} +.notice-error p { + margin: 5px; +} +.warning-custom-modal { + display: none; /* Hidden by default */ + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.4); /* Black with opacity */ +} +/* Modal content */ +.warning-modal-content { + background-color: #fefefe; + margin: 25% auto; + padding: 20px; + border: 1px solid #ddd; + top: 10%; + width: 50%; + max-width: 500px; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} +/* Modal body */ +.warning-modal-body { + padding: 20px 0; +} +/* Modal footer */ +.warning-modal-footer { + padding: 10px 0; + text-align: right; +} +/* Close button */ +.warning-modal-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + position: absolute; + right: 0; + top: 0; +} +.warning-modal-close:hover { + color: #000; +} +.whatsapp-icon { + width: 45px; + height: 40px; + flex-shrink: 0; /* Prevents icon from shrinking */ +} +.contact-info { + padding-left: 10px; + display: flex; + flex-direction: column; +} +.contact-info h3 { + margin: 0; + font-size: 1.1em; +} +.contact-info p { + margin: 0; + font-size: 1.1em; + color: #666; +} +.disconnect-footer-left { + display: flex; + padding: 25px; +} +.disconnect-footer-right-separator { + margin-right:10px; +} +.disconnect-footer-right { + padding: 30px; + margin-left: auto; +} +.disconnect-footer { + display: flex; + position: relative; +} diff --git a/assets/images/whatsapp_icon.png b/assets/images/whatsapp_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7637f5b154396c7e9c6e032e5d23c4ff112427bf GIT binary patch literal 79502 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 DU4yU9*2oXiU8Ny4~w=zEm<);2};MViov)(Pk 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≫5%NX=NsfRHIQ7M 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 kA;ANMh*4nvUw@c~|3VUCaf47N|RsaA1 literal 0 HcmV?d00001 diff --git a/assets/js/admin/whatsapp-billing.js b/assets/js/admin/whatsapp-billing.js new file mode 100644 index 000000000..3ba8c2bb6 --- /dev/null +++ b/assets/js/admin/whatsapp-billing.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + var $billingStepInProgress = $('#wc-fb-whatsapp-billing-inprogress'); + var $billingStepNotStarted = $('#wc-fb-whatsapp-billing-notstarted'); + var $billingStepSuccess = $('#wc-fb-whatsapp-billing-success'); + var $billingSubcontent = $('#wc-fb-whatsapp-billing-subcontent'); + var $billingButtonWrapper = $('#wc-fb-whatsapp-billing-button-wrapper'); + var $whatsappOnboardingDoneButton = $('#whatsapp-onboarding-done-button'); + if (facebook_for_woocommerce_whatsapp_billing.consent_collection_enabled) { + facebook_for_woocommerce_whatsapp_billing.is_payment_setup ? $billingStepSuccess.show() : $billingStepInProgress.show(); + $whatsappOnboardingDoneButton.show(); + $billingStepNotStarted.hide(); + } else { + $billingStepInProgress.hide(); + $billingStepNotStarted.show(); + $billingSubcontent.hide(); + $whatsappOnboardingDoneButton.hide(); + $billingButtonWrapper.hide() + } + + // handle the whatsapp add payment button click should open billing flow in Meta + $('#wc-whatsapp-add-payment').click(function(event) { + + $.post( facebook_for_woocommerce_whatsapp_billing.ajax_url, { + action: 'wc_facebook_whatsapp_fetch_url_info', + nonce: facebook_for_woocommerce_whatsapp_billing.nonce + }, function ( response ) { + if ( response.success ) { + console.log( 'Whatsapp Billing Url Info Fetched Successfully', response ); + var business_id = response.data.business_id; + var asset_id = response.data.waba_id; + const BILLING_URL = `https://business.facebook.com/billing_hub/accounts/details/?business_id=${business_id}&asset_id=${asset_id}&account_type=whatsapp-business-account`; + window.open( BILLING_URL); + } else { + console.log( 'Whatsapp Billing Url Info Fetch Failure', response ); + } + } ); + + + }); + +} ); diff --git a/assets/js/admin/whatsapp-connection.js b/assets/js/admin/whatsapp-connection.js new file mode 100644 index 000000000..e6674fcf6 --- /dev/null +++ b/assets/js/admin/whatsapp-connection.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + var $connectSuccess = $('#wc-fb-whatsapp-connect-success'); + var $connectInProgress = $('#wc-fb-whatsapp-connect-inprogress'); + var $connectSubcontent = $('#wc-fb-whatsapp-onboarding-subcontent'); + var $connectButtonWrapper = $('#wc-fb-whatsapp-onboarding-button-wrapper'); + if (facebook_for_woocommerce_whatsapp_onboarding_progress.whatsapp_onboarding_complete) { + $connectSuccess.show(); + $connectInProgress.hide(); + $connectSubcontent.hide(); + $connectButtonWrapper.hide(); + } else { + $connectSuccess.hide(); + $connectInProgress.show(); + } + + // handle the whatsapp connect button click should open hosted ES flow + $( '#woocommerce-whatsapp-connection' ).click( function( event ) { + const APP_ID = '474166926521348'; // WOO_COMMERCE_APP_ID + const CONFIG_ID = '1237758981048330'; // WOO_COMMERCE_WHATSAPP_CONFIG_ID + const HOSTED_ES_URL = `https://business.facebook.com/messaging/whatsapp/onboard/?app_id=${APP_ID}&config_id=${CONFIG_ID}`; + window.open( HOSTED_ES_URL); + updateProgress(0,1800000); // retry for 30 minutes + }); + + function updateProgress(retryCount = 0, pollingTimeout = 1800000) { + $.post( facebook_for_woocommerce_whatsapp_onboarding_progress.ajax_url, { + action: 'wc_facebook_whatsapp_onboarding_progress_check', + nonce: facebook_for_woocommerce_whatsapp_onboarding_progress.nonce + }, function ( response ) { + + // check if the response is success (i.e. onboarding is completed) + if ( response.success ) { + console.log( 'Whatsapp Connection is Complete', response ); + // update the progress for connect whatsapp step + $connectInProgress.remove(); + $connectSuccess.show(); + // collapse whatsapp onboarding step subcontect and button on success + $connectSubcontent.hide(); + $connectButtonWrapper.hide(); + // update the progress for collect consent step and show button and subcontent + $('#wc-fb-whatsapp-consent-collection-inprogress').show(); + $('#wc-fb-whatsapp-consent-collection-notstarted').hide(); + $('#wc-fb-whatsapp-consent-subcontent').show(); + $('#wc-fb-whatsapp-consent-button-wrapper').show(); + + // update the progress of payment step if payment already setup + if(response.data['is_payment_setup'] === true) { + $('#wc-fb-whatsapp-billing-inprogress').hide(); + $('#wc-fb-whatsapp-billing-notstarted').hide(); + $('#wc-fb-whatsapp-billing-success').show(); + } + } else { + console.log('Whatsapp connection is not complete. Checking again in 5 seconds:', response, ', retry attempt:', retryCount, 'pollingTimeout', pollingTimeout); + if(retryCount >= pollingTimeout) { + console.log('Max retries reached. Aborting.'); + return; + } + setTimeout( function() { updateProgress(retryCount + 1, pollingTimeout); }, 5000 ); + } + } ); + + } + +} ); diff --git a/assets/js/admin/whatsapp-consent-remove.js b/assets/js/admin/whatsapp-consent-remove.js new file mode 100644 index 000000000..de76d9eaf --- /dev/null +++ b/assets/js/admin/whatsapp-consent-remove.js @@ -0,0 +1,94 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + // Get the modal and related elements + var modal = document.getElementById("wc-fb-warning-modal"); + var cancelButton = document.getElementById("wc-fb-warning-modal-cancel"); + var confirmButton = document.getElementById("wc-fb-warning-modal-confirm"); + var $statusElement = $('#wc-whatsapp-collect-consent-status'); + + // On click of the remove button, show the warning modal + $("#wc-whatsapp-collect-consent-remove").click(function(event) { + // Show the modal + modal.style.display = "block"; + + // Prevent default action + event.preventDefault(); + }); + + if (cancelButton) { + // Close modal when clicking the Cancel button + cancelButton.onclick = function() { + modal.style.display = "none"; + }; + } + + if (confirmButton) { + // Handle confirm action + confirmButton.onclick = function() { + // Send the AJAX request to disable WhatsApp consent collection + $.post(facebook_for_woocommerce_whatsapp_consent_remove.ajax_url, { + action: 'wc_facebook_whatsapp_consent_collection_disable', + nonce: facebook_for_woocommerce_whatsapp_consent_remove.nonce + }, function(response) { + if (response.success) { + console.log( 'Whatsapp Consent Collection Disabled Successfully', response ); + // Change the status from "on-status" to "off-status" for the specific element. + $statusElement.removeClass('on-status').addClass('off-status'); + // Update the text to "Off". + $statusElement.text('Off'); + + // Hide the original "Remove" button + $('#wc-whatsapp-collect-consent-remove-container').addClass('fbwa-hidden-element'); + + // Show the "Add" button + $('#wc-whatsapp-collect-consent-add-container').removeClass('fbwa-hidden-element'); + } else { + console.log( 'Whatsapp Consent Collection Disabling Failed', response ); + } + }); + + // Close the modal + modal.style.display = "none"; + }; + } + + // Add event listener to the "Add" button + $('#wc-whatsapp-collect-consent-add').click(function() { + // Send the AJAX request to enable WhatsApp consent collection + $.post(facebook_for_woocommerce_whatsapp_consent.ajax_url, { + action: 'wc_facebook_whatsapp_consent_collection_enable', + nonce: facebook_for_woocommerce_whatsapp_consent.nonce + }, function(response) { + if (response.success) { + console.log( 'Whatsapp Consent Collection Enabled Successfully', response ); + // Change the status from "off-status" to "on-status" for the specific element. + $statusElement.removeClass('off-status').addClass('on-status'); + // Update the text to "On". + $statusElement.text('On'); + + // Hide the "Add" button + $('#wc-whatsapp-collect-consent-add-container').addClass('fbwa-hidden-element'); + + // Show the original "Remove" button + $('#wc-whatsapp-collect-consent-remove-container').removeClass('fbwa-hidden-element'); + } else { + console.log( 'Whatsapp Consent Collection Enabling Failed', response ); + } + }); + }); + + // Close modal when clicking outside of it + window.onclick = function(event) { + if (event.target == modal) { + modal.style.display = "none"; + } + }; +}); diff --git a/assets/js/admin/whatsapp-consent.js b/assets/js/admin/whatsapp-consent.js new file mode 100644 index 000000000..ee64c60c2 --- /dev/null +++ b/assets/js/admin/whatsapp-consent.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + var $consentCollectSuccess = $('#wc-fb-whatsapp-consent-collection-success'); + var $consentCollectInProgress = $('#wc-fb-whatsapp-consent-collection-inprogress'); + var $consentCollectNotStarted = $('#wc-fb-whatsapp-consent-collection-notstarted'); + var $consentSubcontent = $('#wc-fb-whatsapp-consent-subcontent'); + var $consentButtonWrapper = $('#wc-fb-whatsapp-consent-button-wrapper'); + if (facebook_for_woocommerce_whatsapp_consent.whatsapp_onboarding_complete) { + if (facebook_for_woocommerce_whatsapp_consent.consent_collection_enabled) { + showConsentCollectionProgressIcon(true, false, false); + $consentSubcontent.hide(); + $consentButtonWrapper.hide(); + } else { + showConsentCollectionProgressIcon(false, true, false); + } + } else { + showConsentCollectionProgressIcon(false, false, true); + $consentSubcontent.hide(); + $consentButtonWrapper.hide(); + } + + // handle the whatsapp consent collect button click should save setting to wp_options table + $( '#wc-whatsapp-collect-consent' ).click( function( event ) { + + $.post( facebook_for_woocommerce_whatsapp_consent.ajax_url, { + action: 'wc_facebook_whatsapp_consent_collection_enable', + nonce: facebook_for_woocommerce_whatsapp_consent.nonce + }, function ( response ) { + if ( response.success ) { + console.log( 'Whatsapp Consent Collection is Enabled in Checkout Flow', response ); + // update the progress for collect consent step and hide the button and subcontent + showConsentCollectionProgressIcon(true, false, false); + $consentSubcontent.hide(); + $consentButtonWrapper.hide(); + // update the progress of billing step and show the button and subcontent + if(response.data['is_payment_setup'] === true) { + $('#wc-fb-whatsapp-billing-inprogress').hide(); + $('#wc-fb-whatsapp-billing-notstarted').hide(); + $('#wc-fb-whatsapp-billing-success').show(); + } else { + $('#wc-fb-whatsapp-billing-inprogress').show(); + $('#wc-fb-whatsapp-billing-notstarted').hide(); + + } + $('#wc-fb-whatsapp-billing-subcontent').show(); + $('#wc-fb-whatsapp-billing-button-wrapper').show(); + $('#whatsapp-onboarding-done-button').show(); + } else { + console.log( 'Whatsapp Consent Collection Enabling has Failed', response ); + } + } ); + + }); + + function showConsentCollectionProgressIcon(success, inProgress, notStarted) { + if (success) { + $consentCollectSuccess.show(); + } else { + $consentCollectSuccess.hide(); + } + + if (inProgress) { + $consentCollectInProgress.show(); + } else { + $consentCollectInProgress.hide(); + } + + if (notStarted) { + $consentCollectNotStarted.show(); + } else { + $consentCollectNotStarted.hide(); + } + } + +} ); diff --git a/assets/js/admin/whatsapp-disconnect.js b/assets/js/admin/whatsapp-disconnect.js new file mode 100644 index 000000000..91cbde3de --- /dev/null +++ b/assets/js/admin/whatsapp-disconnect.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + // Get the modal and related elements + var modal = document.getElementById("wc-fb-disconnect-warning-modal"); + var cancelButton = document.getElementById("wc-fb-disconnect-warning-modal-cancel"); + var confirmButton = document.getElementById("wc-fb-disconnect-warning-modal-confirm"); + + // On click of the remove button, show the warning modal + $("#wc-whatsapp-disconnect-button").click(function(event) { + // Show the modal + modal.style.display = "block"; + + // Prevent default action + event.preventDefault(); + }); + + if (cancelButton) { + // Close modal when clicking the Cancel button + cancelButton.onclick = function() { + modal.style.display = "none"; + }; + } + + if (confirmButton) { + // Handle confirm action + confirmButton.onclick = function() { + $.post( facebook_for_woocommerce_whatsapp_disconnect.ajax_url, { + action: 'wc_facebook_disconnect_whatsapp', + nonce: facebook_for_woocommerce_whatsapp_disconnect.nonce + }, function ( response ) { + if ( response.success ) { + let url = new URL(window.location.href); + let params = new URLSearchParams(url.search); + params.delete('view'); + url.search = params.toString(); + window.location.href = url.toString(); + console.log( 'Whatsapp Disconnect Success', response ); + } else { + console.log("Whatsapp Disconnect Failure!!!",response); + } + } ); + + // Close the modal + modal.style.display = "none"; + }; + } + + // handle whatsapp disconnect widget edit link click should open business manager with whatsapp asset selected + $( '#wc-whatsapp-disconnect-edit' ).click( function( event ) { + $.post( facebook_for_woocommerce_whatsapp_disconnect.ajax_url, { + action: 'wc_facebook_whatsapp_fetch_url_info', + nonce: facebook_for_woocommerce_whatsapp_disconnect.nonce + }, function ( response ) { + + if ( response.success ) { + console.log( 'Whatsapp Edit Url Info Fetched Successfully', response ); + var business_id = response.data.business_id; + var asset_id = response.data.waba_id; + const WHATSAPP_MANAGER_URL = `https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?asset_id=${asset_id}&business_id=${business_id}`; + window.open(WHATSAPP_MANAGER_URL); + } else { + console.log( 'Whatsapp Edit Url Info Fetch Failure', response ); + } + } ); + }); + +} ); diff --git a/assets/js/admin/whatsapp-events.js b/assets/js/admin/whatsapp-events.js new file mode 100644 index 000000000..bfaed97df --- /dev/null +++ b/assets/js/admin/whatsapp-events.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + // Set Event Status for Order Placed + var orderPlacedActiveStatus = $('#order-placed-active-status'); + var orderPlacedInactiveStatus = $('#order-placed-inactive-status'); + if(facebook_for_woocommerce_whatsapp_events.order_placed_enabled){ + orderPlacedInactiveStatus.hide(); + orderPlacedActiveStatus.show(); + } + else { + orderPlacedActiveStatus.hide(); + orderPlacedInactiveStatus.show(); + } + + // Set Event Status for Order FulFilled + var orderFulfilledActiveStatus = $('#order-fulfilled-active-status'); + var orderFulfilledInactiveStatus = $('#order-fulfilled-inactive-status'); + if(facebook_for_woocommerce_whatsapp_events.order_fulfilled_enabled){ + orderFulfilledInactiveStatus.hide(); + orderFulfilledActiveStatus.show(); + } + else { + orderFulfilledActiveStatus.hide(); + orderFulfilledInactiveStatus.show(); + } + + // Set Event Status for Order Refunded + var orderRefundedActiveStatus = $('#order-refunded-active-status'); + var orderRefundedInactiveStatus = $('#order-refunded-inactive-status'); + if(facebook_for_woocommerce_whatsapp_events.order_refunded_enabled){ + orderRefundedInactiveStatus.hide(); + orderRefundedActiveStatus.show(); + } + else { + orderRefundedActiveStatus.hide(); + orderRefundedInactiveStatus.show(); + } + + var eventConfiglanguage = getEventLanguage(facebook_for_woocommerce_whatsapp_events.event); + $("#manage-event-language").val(eventConfiglanguage); + + $('#woocommerce-whatsapp-manage-order-placed, #woocommerce-whatsapp-manage-order-fulfilled, #woocommerce-whatsapp-manage-order-refunded').click(function (event) { + var clickedButtonId = $(event.target).attr("id"); + let view=clickedButtonId.replace("woocommerce-whatsapp-", ""); + view = view.replaceAll("-", "_"); + let url = new URL(window.location.href); + let params = new URLSearchParams(url.search); + params.set('view', view); + url.search = params.toString(); + window.location.href = url.toString(); + }); + + // call template library get API to show message template header, body and button text configured for the event. + $("#library-template-content").load(facebook_for_woocommerce_whatsapp_events.ajax_url, function () { + $.post(facebook_for_woocommerce_whatsapp_events.ajax_url, { + action: 'wc_facebook_whatsapp_fetch_library_template_info', + nonce: facebook_for_woocommerce_whatsapp_events.nonce, + event: facebook_for_woocommerce_whatsapp_events.event, + }, function (response) { + if (response.success) { + const parsedData = JSON.parse(response.data); + const apiResponseData = parsedData.data[0]; + // Parse template strings as HTML and extract text content to sanitize text + const header = $.parseHTML(apiResponseData.header)[0].textContent; + const body = $.parseHTML(apiResponseData.body)[0].textContent; + if (facebook_for_woocommerce_whatsapp_events.event === "ORDER_REFUNDED") { + $('#library-template-content').html(` +

Header

+

${header}

+

Body

+

${body}

+ `).show(); + } + else { + const button = $.parseHTML(apiResponseData.buttons[0].text)[0].textContent; + $('#library-template-content').html(` +

Header

+

${header}

+

Body

+

${body}

+

Call to action

+

${button}

+ `).show(); + } + } + }); + }); + + $('#woocommerce-whatsapp-save-order-confirmation').click(function (event) { + var languageValue = $("#manage-event-language").val(); + var statusValue = $('input[name="template-status"]:checked').val(); + console.log('Save confirmation clicked: ', languageValue, statusValue); + $.post(facebook_for_woocommerce_whatsapp_events.ajax_url, { + action: 'wc_facebook_whatsapp_upsert_event_config', + nonce: facebook_for_woocommerce_whatsapp_events.nonce, + event: facebook_for_woocommerce_whatsapp_events.event, + language: languageValue, + status: statusValue + }, function (response) { + //TODO: Add Error Handling + let url = new URL(window.location.href); + let params = new URLSearchParams(url.search); + params.set('view', 'utility_settings'); + url.search = params.toString(); + window.location.href = url.toString(); + }); + }); + + function getEventLanguage(event) { + switch (event) { + case "ORDER_PLACED": + return facebook_for_woocommerce_whatsapp_events.order_placed_language; + case "ORDER_FULFILLED": + return facebook_for_woocommerce_whatsapp_events.order_fulfilled_language; + case "ORDER_REFUNDED": + return facebook_for_woocommerce_whatsapp_events.order_refunded_language; + default: + return null; + } + } +}); \ No newline at end of file diff --git a/assets/js/admin/whatsapp-finish.js b/assets/js/admin/whatsapp-finish.js new file mode 100644 index 000000000..98d22e8ea --- /dev/null +++ b/assets/js/admin/whatsapp-finish.js @@ -0,0 +1,53 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + // handle the whatsapp finish button click + $( '#wc-whatsapp-onboarding-finish' ).click( function( event ) { + // call the connect API to create configs and check payment + $.post( facebook_for_woocommerce_whatsapp_finish.ajax_url, { + action: 'wc_facebook_whatsapp_finish_onboarding', + nonce: facebook_for_woocommerce_whatsapp_finish.nonce + }, function ( response ) { + if ( response.success ) { + // If success, redirect to utility settings page + let url = new URL(window.location.href); + let params = new URLSearchParams(url.search); + params.set('view', 'utility_settings'); + url.search = params.toString(); + window.location.href = url.toString(); + console.log( 'Whatsapp Connect Success', response ); + } else { + var message; + const error = response.data; + console.log( 'Whatsapp Connect Failure', response ); + + switch (error) { + case "Incorrect payment setup": + message = facebook_for_woocommerce_whatsapp_finish.i18n.payment_setup_error; + break; + case "Onboarding is not complete or has failed.": + message = facebook_for_woocommerce_whatsapp_finish.i18n.onboarding_incomplete_error; + break; + default: + message = facebook_for_woocommerce_whatsapp_finish.i18n.generic_error; + } + + + const errorNoticeHtml = ` +
+

${message}

+
+ `; + $( '#payment-method-error-notice' ).html( errorNoticeHtml ).show(); + } + } ); + }); + +} ); diff --git a/assets/js/admin/whatsapp-templates.js b/assets/js/admin/whatsapp-templates.js new file mode 100644 index 000000000..be01ba924 --- /dev/null +++ b/assets/js/admin/whatsapp-templates.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + * + * @package FacebookCommerce + */ + +jQuery( document ).ready( function( $ ) { + // handle whatsapp view insights link click should open template insights in WhatsSpp Manager + $( '#woocommerce-whatsapp-manager-insights' ).click( function( event ) { + $.post( facebook_for_woocommerce_whatsapp_templates.ajax_url, { + action: 'wc_facebook_whatsapp_fetch_url_info', + nonce: facebook_for_woocommerce_whatsapp_templates.nonce + }, function ( response ) { + console.log(response); + if ( response.success ) { + var business_id = response.data.business_id; + var asset_id = response.data.waba_id; + const MANAGE_TEMPLATES_URL = `https://business.facebook.com/latest/whatsapp_manager/message_templates?business_id=${business_id}&asset_id=${asset_id}`; + window.open(MANAGE_TEMPLATES_URL); + } + } ); + }); +} ); diff --git a/class-wc-facebookcommerce.php b/class-wc-facebookcommerce.php index 56bd4b208..b9a184ef1 100644 --- a/class-wc-facebookcommerce.php +++ b/class-wc-facebookcommerce.php @@ -90,6 +90,9 @@ class WC_Facebookcommerce extends WooCommerce\Facebook\Framework\Plugin { /** @var WooCommerce\Facebook\Handlers\WebHook webhook handler */ private $webhook_handler; + /** @var WooCommerce\Facebook\Handlers\Whatsapp_WebHook whatsapp webhook handler */ + private $whatsapp_webhook_handler; + /** @var WooCommerce\Facebook\Commerce commerce handler */ private $commerce_handler; @@ -163,6 +166,7 @@ public function init() { add_action( 'init', array( $this, 'get_integration' ) ); add_action( 'init', array( $this, 'register_custom_taxonomy' ) ); add_action( 'add_meta_boxes_product', array( $this, 'remove_product_fb_product_set_metabox' ), 50 ); + add_action( 'woocommerce_init', array($this, 'add_whatsapp_consent_checkout_fields')); add_filter( 'fb_product_set_row_actions', array( $this, 'product_set_links' ) ); add_filter( 'manage_edit-fb_product_set_columns', array( $this, 'manage_fb_product_set_columns' ) ); @@ -186,7 +190,6 @@ public function init() { $this->heartbeat = new Heartbeat( WC()->queue() ); $this->heartbeat->init(); - $this->product_feed = new WooCommerce\Facebook\Products\Feed(); $this->products_stock_handler = new WooCommerce\Facebook\Products\Stock(); $this->products_sync_handler = new WooCommerce\Facebook\Products\Sync(); @@ -216,24 +219,28 @@ public function init() { $this->connection_handler = new WooCommerce\Facebook\Handlers\Connection( $this ); $this->webhook_handler = new WooCommerce\Facebook\Handlers\WebHook( $this ); + $this->whatsapp_webhook_handler = new WooCommerce\Facebook\Handlers\Whatsapp_Webhook( $this ); $this->tracker = new WooCommerce\Facebook\Utilities\Tracker(); $this->rollout_switches = new WooCommerce\Facebook\RolloutSwitches( $this ); + // Init jobs $this->job_manager = new WooCommerce\Facebook\Jobs\JobManager(); add_action( 'init', [ $this->job_manager, 'init' ] ); + add_action( 'admin_init', [ $this->rollout_switches, 'init' ] ); // Instantiate the debug tools. $this->debug_tools = new DebugTools(); // load admin handlers, before admin_init if ( is_admin() ) { - $this->admin_settings = new WooCommerce\Facebook\Admin\Settings( $this->connection_handler->is_connected() ); + $this->admin_settings = new WooCommerce\Facebook\Admin\Settings( $this ); } } } + /** * Initializes the admin handling. * @@ -249,7 +256,6 @@ function () { }, 0 ); - add_action( 'admin_init', [ $this->rollout_switches, 'init' ] ); } /** @@ -857,6 +863,30 @@ protected function get_current_page_id() { } return $current_screen_id; } + + /** + * Add checkout fields to collect whatsapp consent if consent collection is enabled + * + * @since 2.3.0 + * + * @param array $fields + * + * @return array + */ + function add_whatsapp_consent_checkout_fields($fields) { + if (get_option('wc_facebook_whatsapp_consent_collection_setting_status', 'disabled') === 'enabled') { + woocommerce_register_additional_checkout_field( + array( + 'id' => 'wc_facebook/whatsapp_consent_checkbox', // id = namespace/field_name + 'label' => esc_html('Get order updates on WhatsApp'), + 'location' => 'address', + 'type' => 'checkbox', + 'optionalLabel' => esc_html('Get order updates on WhatsApp') + ) + ); + } + return $fields; + } } diff --git a/facebook-commerce-whatsapp-utility-event.php b/facebook-commerce-whatsapp-utility-event.php new file mode 100644 index 000000000..9fef14afa --- /dev/null +++ b/facebook-commerce-whatsapp-utility-event.php @@ -0,0 +1,131 @@ + 'ORDER_PLACED', + 'completed' => 'ORDER_FULFILLED', + 'refunded' => 'ORDER_REFUNDED', + ); + + public function __construct() { + if ( ! $this->is_whatsapp_utility_enabled() ) { + return; + } + add_action( 'woocommerce_order_status_changed', array( $this, 'process_wc_order_status_changed' ), 10, 3 ); + } + + /** + * Determines if WhatsApp Utility Messages are enabled + * TODO: Update this function to check for gating logic for Alpha businesses + * + * @since 2.3.0 + * + * @return bool + */ + private function is_whatsapp_utility_enabled() { + return true; + } + + + /** + * Hook to process Order Processing, Order Completed and Order Refunded events for WhatsApp Utility Messages + * + * @param string $order_id Order id + * @param string $old_status Old Order Status + * @param string $new_status New Order Status + * + * @return void + * @since 2.3.0 + */ + public function process_wc_order_status_changed( $order_id, $old_status, $new_status ) { + // WhatsApp Utility Messages are supported only for Processing status + $supported_statuses = array_keys( self::ORDER_STATUS_TO_EVENT_MAPPING ); + if ( ! in_array( $new_status, $supported_statuses, true ) ) { + return; + } + + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id */ + __( 'Processing Order id %1$s to send Whatsapp Utility messages', 'facebook-for-woocommerce' ), + $order_id, + ) + ); + $event = self::ORDER_STATUS_TO_EVENT_MAPPING[ $new_status ]; + + // Check WhatsApp Event Config is active + $event_config_id_option_name = implode( '_', array( WhatsAppUtilityConnection::WA_UTILITY_OPTION_PREFIX, strtolower( $event ), 'event_config_id' ) ); + $event_config_language_option_name = implode( '_', array( WhatsAppUtilityConnection::WA_UTILITY_OPTION_PREFIX, strtolower( $event ), 'language' ) ); + $event_config_id = get_option( $event_config_id_option_name, null ); + $language_code = get_option( $event_config_language_option_name, null ); + if ( empty( $event_config_id ) || empty( $language_code ) ) { + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id */ + __( 'Messages Post API call for Order id %1$s skipped due to no active event config', 'facebook-for-woocommerce' ), + $order_id, + ) + ); + return; + } + + $order = wc_get_order( $order_id ); + // Check WhatsApp Consent Checkbox is selected in shipping and billing + $billing_consent_value = $order->get_meta( '_wc_billing/wc_facebook/whatsapp_consent_checkbox' ); + $shipping_consent_value = $order->get_meta( '_wc_shipping/wc_facebook/whatsapp_consent_checkbox' ); + $has_whatsapp_consent = $billing_consent_value && $shipping_consent_value; + // Get WhatsApp Phone number from entered Billing and Shipping phone number + $billing_phone_number = $order->get_billing_phone(); + $shipping_phone_number = $order->get_shipping_phone(); + $phone_number = ( isset( $billing_phone_number ) && $billing_consent_value ) ? $billing_phone_number : $shipping_phone_number; + // Get Customer first name + $first_name = $order->get_billing_first_name(); + // Get Total Refund Amount for Order Refunded event + $total_refund = 0; + foreach ( $order->get_refunds() as $refund ) { + $total_refund += $refund->get_amount(); + } + $currency = $order->get_currency(); + $refund_amount = $total_refund * 1000; + if ( empty( $phone_number ) || ! $has_whatsapp_consent || empty( $event ) || empty( $first_name ) ) { + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id */ + __( 'Messages Post API call for Order id %1$s skipped due to missing whatsapp consent or Order info', 'facebook-for-woocommerce' ), + $order_id, + ) + ); + return; + } + + // Check Access token and WACS is available + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + $wacs_id = get_option( 'wc_facebook_wa_integration_wacs_id', null ); + if ( empty( $bisu_token ) || empty( $wacs_id ) ) { + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id */ + __( 'Messages Post API call for Order id %1$s Failed due to missing access token or wacs info', 'facebook-for-woocommerce' ), + $order_id, + ) + ); + return; + } + WhatsAppUtilityConnection::post_whatsapp_utility_messages_events_call( $event, $event_config_id, $language_code, $wacs_id, $order_id, $phone_number, $first_name, $refund_amount, $currency, $bisu_token ); + } +} diff --git a/facebook-commerce.php b/facebook-commerce.php index be88640c3..580514fec 100644 --- a/facebook-commerce.php +++ b/facebook-commerce.php @@ -173,6 +173,9 @@ class WC_Facebookcommerce_Integration extends WC_Integration { /** @var WC_Facebook_Product_Feed instance. */ private $fbproductfeed; + /** @var WC_Facebookcommerce_Whatsapp_Utility_Event instance. */ + private $wa_utility_event_processor; + /** * Init and hook in the integration. * @@ -360,6 +363,9 @@ public function __construct( WC_Facebookcommerce $facebook_for_woocommerce ) { // Product Set hooks. add_action( 'fb_wc_product_set_sync', [ $this, 'create_or_update_product_set_item' ], 99, 2 ); add_action( 'fb_wc_product_set_delete', [ $this, 'delete_product_set_item' ], 99 ); + + // Init Whatsapp Utility Event Processor + $this->wa_utility_event_processor = $this->load_whatsapp_utility_event_processor(); } /** @@ -3027,4 +3033,19 @@ public function ajax_display_test_result() { wp_die(); } + /** + * Init WhatsApp Utility Event Processor. + * + * @return void + */ + public function load_whatsapp_utility_event_processor() { + // Attempt to load WhatsApp Utility Event Processor + include_once 'facebook-commerce-whatsapp-utility-event.php'; + if ( class_exists( 'WC_Facebookcommerce_Whatsapp_Utility_Event' ) ) { + if ( ! isset( $this->wa_utility_event_processor ) ) { + $this->wa_utility_event_processor = new WC_Facebookcommerce_Whatsapp_Utility_Event( $this ); + } + } + } + } diff --git a/includes/AJAX.php b/includes/AJAX.php index b92243f1d..72108984f 100644 --- a/includes/AJAX.php +++ b/includes/AJAX.php @@ -14,6 +14,7 @@ use WooCommerce\Facebook\Framework\Helper; use WooCommerce\Facebook\Admin\Settings_Screens\Product_Sync; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; +use WooCommerce\Facebook\Handlers\WhatsAppUtilityConnection; defined( 'ABSPATH' ) or exit; @@ -46,8 +47,32 @@ public function __construct() { // get the current sync status add_action( 'wp_ajax_wc_facebook_get_sync_status', array( $this, 'get_sync_status' ) ); + // check the status of whatsapp onboarding and update the progress + add_action( 'wp_ajax_wc_facebook_whatsapp_onboarding_progress_check', array( $this, 'whatsapp_onboarding_progress_check' ) ); + + // update the wp_options with wc_facebook_whatsapp_consent_collection_setting_status to enabled + add_action( 'wp_ajax_wc_facebook_whatsapp_consent_collection_enable', array( $this, 'whatsapp_consent_collection_enable' ) ); + + // fetch url info - waba id and business id + add_action( 'wp_ajax_wc_facebook_whatsapp_fetch_url_info', array( $this, 'wc_facebook_whatsapp_fetch_url_info' ) ); + + // action to fetch required info and make api call to meta to finish onboarding + add_action( 'wp_ajax_wc_facebook_whatsapp_finish_onboarding', array( $this, 'wc_facebook_whatsapp_finish_onboarding' ) ); + + // fetch configured library template info + add_action( 'wp_ajax_wc_facebook_whatsapp_fetch_library_template_info', array( $this, 'whatsapp_fetch_library_template_info' ) ); + + // action to create or update utility event config info + add_action( 'wp_ajax_wc_facebook_whatsapp_upsert_event_config', array( $this, 'whatsapp_upsert_event_config' ) ); + // search a product's attributes for the given term add_action( 'wp_ajax_' . self::ACTION_SEARCH_PRODUCT_ATTRIBUTES, array( $this, 'admin_search_product_attributes' ) ); + + // update the wp_options with wc_facebook_whatsapp_consent_collection_setting_status to disabled + add_action( 'wp_ajax_wc_facebook_whatsapp_consent_collection_disable', array( $this, 'whatsapp_consent_collection_disable' ) ); + + // disconnect whatsapp account from woocommcerce app + add_action( 'wp_ajax_wc_facebook_disconnect_whatsapp', array( $this, 'wc_facebook_disconnect_whatsapp' ) ); } @@ -156,6 +181,241 @@ public function get_sync_status() { wp_send_json_success( $remaining_products ); } + /** + * Get data for creating the billing or whatsapp manager url for whatsapp account. + * + * @internal + * + * @since 1.10.0 + */ + public function wc_facebook_whatsapp_fetch_url_info() { + wc_get_logger()->info( + sprintf( + __( 'Fetching url info(WABA ID+BusinessID) for whatsapp pages', 'facebook-for-woocommerce' ) + ) + ); + facebook_for_woocommerce()->log( '' ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-billing-nonce', 'nonce', false ) && ! check_ajax_referer( 'facebook-for-wc-whatsapp-templates-nonce', 'nonce', false ) && ! check_ajax_referer( 'facebook-for-wc-whatsapp-disconnect-nonce', 'nonce', false ) ) { + wc_get_logger()->info( + sprintf( + __( 'Nonce Verification Error while Fetching Url Info', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Invalid security token sent.' ); + } + + $waba_id = get_option( 'wc_facebook_wa_integration_waba_id', null ); + $business_id = get_option( 'wc_facebook_wa_integration_business_id', null ); + + if ( empty( $waba_id ) || empty( $business_id ) ) { + wc_get_logger()->info( + sprintf( + __( 'Missing Waba ID + Business ID during Fetch Url Info. Whatsapp Onboarding is not complete or has failed.', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Whatsapp onboarding is not complete or has failed.' ); + } + + $response = array( + 'waba_id' => $waba_id, + 'business_id' => $business_id, + ); + + wp_send_json_success( $response ); + } + + /** + * Get data for for finish onboarding call and make api call. + * + * @internal + * + * @since 1.10.0 + */ + public function wc_facebook_whatsapp_finish_onboarding() { + wc_get_logger()->info( + sprintf( + __( 'Getting data for Whatsapp Finish Onboarding Done Button Click', 'facebook-for-woocommerce' ) + ) + ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-finish-nonce', 'nonce', false ) ) { + wc_get_logger()->info( + sprintf( + __( 'Nonce Verification Error in Finish Onboarding Flow', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Invalid security token sent.' ); + } + $external_business_id = get_option( 'wc_facebook_external_business_id', null ); + $wacs_id = get_option( 'wc_facebook_wa_integration_wacs_id', null ); + $waba_id = get_option( 'wc_facebook_wa_integration_waba_id', null ); + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + if ( empty( $external_business_id ) || empty( $wacs_id ) || empty( $waba_id ) || empty( $bisu_token ) ) { + wc_get_logger()->info( + sprintf( + __( 'Finish Onboarding - Onboarding is not complete or has failed.', 'facebook-for-woocommerce' ), + ) + ); + wp_send_json_error( 'Onboarding Flow is not complete or has failed.' ); + } + WhatsAppUtilityConnection::wc_facebook_whatsapp_connect_utility_messages_call( $waba_id, $wacs_id, $external_business_id, $bisu_token ); + } + + + /** + * Checks if the onboarding for whatsapp is complete once business has initiated onboarding. + * + * @internal + * + * @since 1.10.0 + */ + public function whatsapp_onboarding_progress_check() { + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-onboarding-progress-nonce', 'nonce', false ) ) { + wp_send_json_error( 'Invalid security token sent.' ); + } + $waba_id = get_option( 'wc_facebook_wa_integration_waba_id', null ); + $is_payment_setup = (bool) get_option( 'wc_facebook_wa_integration_is_payment_setup', null ); + if ( ! empty( $waba_id ) ) { + wp_send_json_success( + array( + 'message' => 'WhatsApp onboarding is complete', + 'is_payment_setup' => $is_payment_setup, + ) + ); + } + wp_send_json_error( 'WhatsApp onboarding is not complete' ); + } + + public function whatsapp_consent_collection_enable() { + wc_get_logger()->info( + sprintf( + __( 'Enabling Whatsapp Consent Collection in Checkout Flow', 'facebook-for-woocommerce' ) + ) + ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-consent-nonce', 'nonce', false ) ) { + wc_get_logger()->info( + sprintf( + __( 'Nonce Verification Error in Whatsapp Consent Collection', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Invalid security token sent.' ); + } + if ( get_option( 'wc_facebook_whatsapp_consent_collection_setting_status' ) !== 'enabled' ) { + update_option( 'wc_facebook_whatsapp_consent_collection_setting_status', 'enabled' ); + } + $is_payment_setup = (bool) get_option( 'wc_facebook_wa_integration_is_payment_setup', null ); + wc_get_logger()->info( + sprintf( + __( 'Whatsapp Consent Collection Enabled Successfully in Checkout Flow', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_success( + array( + 'message' => 'Whatsapp Consent Collection Enabled Successfully in Checkout Flow', + 'is_payment_setup' => $is_payment_setup, + ) + ); + } + + public function whatsapp_consent_collection_disable() { + wc_get_logger()->info( + sprintf( + __( 'Disabling Whatsapp Consent Collection in Utility Settings View', 'facebook-for-woocommerce' ) + ) + ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-consent-disable-nonce', 'nonce', false ) ) { + wp_send_json_error( 'Invalid security token sent.' ); + } + if ( get_option( 'wc_facebook_whatsapp_consent_collection_setting_status' ) !== 'disabled' ) { + update_option( 'wc_facebook_whatsapp_consent_collection_setting_status', 'disabled' ); + } + wc_get_logger()->info( + sprintf( + __( 'Whatsapp Consent Collection Disabled Successfully in Utility Settings View', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_success(); + } + + /** + * Disconnect Whatsapp from WooCommerce. + * + * @internal + * + * @since 1.10.0 + */ + public function wc_facebook_disconnect_whatsapp() { + wc_get_logger()->info( + sprintf( + __( 'Diconnecting Whatsapp From Woocommerce', 'facebook-for-woocommerce' ) + ) + ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-disconnect-nonce', 'nonce', false ) ) { + wc_get_logger()->info( + sprintf( + __( 'Nonce Verification Failed while Diconnecting Whatsapp From Woocommerce', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Invalid security token sent.' ); + } + + $integration_config_id = get_option( 'wc_facebook_wa_integration_config_id', null ); + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + $waba_id = get_option( 'wc_facebook_wa_integration_waba_id', null ); + if ( empty( $integration_config_id ) || empty( $bisu_token ) || empty( $waba_id ) ) { + wc_get_logger()->info( + sprintf( + __( 'Missing Integration Config ID, BISU token, WABA ID while Diconnecting Whatsapp From Woocommerce', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Missing integration_config_id or bisu_token or waba_id for Disconnect API call' ); + } + WhatsAppUtilityConnection::wc_facebook_disconnect_whatsapp( $waba_id, $integration_config_id, $bisu_token ); + } + + public function whatsapp_fetch_library_template_info() { + facebook_for_woocommerce()->log( 'Fetching library template data for whatsapp utility event' ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-events-nonce', 'nonce', false ) ) { + wp_send_json_error( 'Invalid security token sent.' ); + } + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + if ( empty( $bisu_token ) ) { + wp_send_json_error( 'Missing access token for Library template API call' ); + } + // Get POST parameters from the request + $event = isset( $_POST['event'] ) ? wc_clean( wp_unslash( $_POST['event'] ) ) : ''; + WhatsAppUtilityConnection::get_template_library_content( $event, $bisu_token ); + } + /** + * Creates or Updates WhatsApp Utility Event Configs + * + * @internal + * + * @since 1.10.0 + */ + public function whatsapp_upsert_event_config() { + facebook_for_woocommerce()->log( 'Calling POST API to upsert whatsapp utility event' ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-events-nonce', 'nonce', false ) ) { + wp_send_json_error( 'Invalid security token sent.' ); + } + // Get BISU token + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + if ( empty( $bisu_token ) ) { + wp_send_json_error( 'Missing access token for Event Configs POST API call' ); + } + // Get Integration Config id + $integration_config_id = get_option( 'wc_facebook_wa_integration_config_id', null ); + if ( empty( $integration_config_id ) ) { + wp_send_json_error( 'Missing Integration Config for Event Configs POST API call' ); + } + // Get POST parameters from the request + $event = isset( $_POST['event'] ) ? wc_clean( wp_unslash( $_POST['event'] ) ) : ''; + $language = isset( $_POST['language'] ) ? wc_clean( wp_unslash( $_POST['language'] ) ) : ''; + $status = isset( $_POST['status'] ) ? wc_clean( wp_unslash( $_POST['status'] ) ) : ''; + if ( empty( $event ) || empty( $language ) || empty( $status ) ) { + wp_send_json_error( 'Missing request parameters for Event Configs POST API call' ); + } + WhatsAppUtilityConnection::post_whatsapp_utility_messages_event_configs_call( $event, $integration_config_id, $language, $status, $bisu_token ); + } /** * Maybe triggers a modal warning when the merchant toggles sync enabled status in bulk. diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php index 2b3f48695..8bdf66780 100644 --- a/includes/Admin/Settings.php +++ b/includes/Admin/Settings.php @@ -13,8 +13,10 @@ use Automattic\WooCommerce\Admin\Features\Features as WooAdminFeatures; use WooCommerce\Facebook\Admin\Settings_Screens; use WooCommerce\Facebook\Admin\Settings_Screens\Connection; +use WooCommerce\Facebook\Admin\Settings_Screens\Whatsapp_Utility; use WooCommerce\Facebook\Framework\Helper; use WooCommerce\Facebook\Framework\Plugin\Exception as PluginException; +use WooCommerce\Facebook\RolloutSwitches; defined( 'ABSPATH' ) || exit; @@ -38,16 +40,23 @@ class Settings { /** @var Abstract_Settings_Screen[] */ private $screens; + /** @var \WC_Facebookcommerce */ + private $plugin; + /** * Settings constructor. * - * @param bool $is_connected is the state of the plugin connection to the Facebook Marketing API + * @param \WC_Facebookcommerce $plugin is the plugin instance of WC_Facebookcommerce * @since 2.0.0 */ - public function __construct( bool $is_connected ) { + public function __construct( \WC_Facebookcommerce $plugin ) { + + $this->plugin = $plugin; - $this->screens = $this->build_menu_item_array( $is_connected ); + $this->screens = $this->build_menu_item_array(); + add_action( 'admin_menu', array( $this, 'build_menu_item_array' ) ); + add_action( 'admin_init', array( $this, 'add_extra_screens' ) ); add_action( 'admin_menu', array( $this, 'add_menu_item' ) ); add_action( 'wp_loaded', array( $this, 'save' ) ); add_filter( 'parent_file', array( $this, 'set_parent_and_submenu_file' ) ); @@ -58,15 +67,15 @@ public function __construct( bool $is_connected ) { /** * Arranges the tabs. If the plugin is connected to FB, Advertise tab will be first, otherwise the Connection tab will be the first tab. * - * @param bool $is_connected is Facebook connected * @since 3.0.7 */ - private function build_menu_item_array( bool $is_connected ): array { + public function build_menu_item_array(): array { $advertise = [ Settings_Screens\Advertise::ID => new Settings_Screens\Advertise() ]; $connection = [ Settings_Screens\Connection::ID => new Settings_Screens\Connection() ]; - $first = ( $is_connected ) ? $advertise : $connection; - $last = ( $is_connected ) ? $connection : $advertise; + $is_connected = $this->plugin->get_connection_handler()->is_connected(); + $first = ( $is_connected ) ? $advertise : $connection; + $last = ( $is_connected ) ? $connection : $advertise; $screens = array( Settings_Screens\Product_Sync::ID => new Settings_Screens\Product_Sync(), @@ -76,6 +85,15 @@ private function build_menu_item_array( bool $is_connected ): array { return array_merge( array_merge( $first, $screens ), $last ); } + public function add_extra_screens(): void { + $rollout_switches = $this->plugin->get_rollout_switches(); + $is_connected = $this->plugin->get_connection_handler()->is_connected(); + $is_whatsapp_utility_messaging_enabled = $rollout_switches->is_switch_enabled( RolloutSwitches::WHATSAPP_UTILITY_MESSAGING ); + if ( true === $is_connected && true === $is_whatsapp_utility_messaging_enabled ) { + $this->screens[ Settings_Screens\Whatsapp_Utility::ID ] = new Settings_Screens\Whatsapp_Utility(); + } + } + /** * Adds the Facebook menu item. * @@ -219,7 +237,18 @@ public function render_tabs( $current_tab ) { ?> initHook(); + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Initializes this whatsapp utility settings page's properties. + */ + public function initHook(): void { + $this->id = self::ID; + $this->label = __( 'Utility messages', 'facebook-for-woocommerce' ); + $this->title = __( 'Utility messages', 'facebook-for-woocommerce' ); + } + + /** + * Enqueue the assets. + * + * @internal + * + * @since 2.0.0 + */ + public function enqueue_assets() { + + if ( ! $this->is_current_screen_page() ) { + return; + } + + wp_enqueue_style( 'wc-facebook-admin-whatsapp-settings', facebook_for_woocommerce()->get_plugin_url() . '/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css', array(), \WC_Facebookcommerce::VERSION ); + wp_enqueue_script( + 'facebook-for-woocommerce-connect-whatsapp', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-connection.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + $waba_id = get_option( 'wc_facebook_wa_integration_waba_id', '' ); + $whatsapp_connected = ! empty( $waba_id ); + wp_localize_script( + 'facebook-for-woocommerce-connect-whatsapp', + 'facebook_for_woocommerce_whatsapp_onboarding_progress', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-onboarding-progress-nonce' ), + 'whatsapp_onboarding_complete' => $whatsapp_connected, + 'i18n' => array( + 'result' => true, + ), + ) + ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-consent', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-consent.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + $consent_collection_enabled = get_option( 'wc_facebook_whatsapp_consent_collection_setting_status', null ) === 'enabled'; + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-consent', + 'facebook_for_woocommerce_whatsapp_consent', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-consent-nonce' ), + 'whatsapp_onboarding_complete' => $whatsapp_connected, + 'consent_collection_enabled' => $consent_collection_enabled, + 'i18n' => array( + 'result' => true, + ), + ) + ); + $is_payment_setup = (bool) get_option( 'wc_facebook_wa_integration_is_payment_setup', null ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-billing', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-billing.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-billing', + 'facebook_for_woocommerce_whatsapp_billing', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-billing-nonce' ), + 'consent_collection_enabled' => $consent_collection_enabled, + 'is_payment_setup' => $is_payment_setup, + 'i18n' => array( + 'result' => true, + ), + ) + ); + $order_placed_event_config_id = get_option( 'wc_facebook_wa_order_placed_event_config_id', null ); + $order_placed_language = get_option( 'wc_facebook_wa_order_placed_language', 'en' ); + $order_fulfilled_event_config_id = get_option( 'wc_facebook_wa_order_fulfilled_event_config_id', null ); + $order_fulfilled_language = get_option( 'wc_facebook_wa_order_fulfilled_language', 'en' ); + $order_refunded_event_config_id = get_option( 'wc_facebook_wa_order_refunded_event_config_id', null ); + $order_refunded_language = get_option( 'wc_facebook_wa_order_refunded_language', 'en' ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-events', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-events.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-events', + 'facebook_for_woocommerce_whatsapp_events', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-events-nonce' ), + 'event' => $this->get_current_event(), + 'order_placed_enabled' => ! empty( $order_placed_event_config_id ), + 'order_placed_language' => $order_placed_language, + 'order_fulfilled_enabled' => ! empty( $order_fulfilled_event_config_id ), + 'order_fulfilled_language' => $order_fulfilled_language, + 'order_refunded_enabled' => ! empty( $order_refunded_event_config_id ), + 'order_refunded_language' => $order_refunded_language, + 'i18n' => array( + 'result' => true, + ), + ) + ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-finish', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-finish.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-finish', + 'facebook_for_woocommerce_whatsapp_finish', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-finish-nonce' ), + 'i18n' => array( // will generate i18 pot translation + 'payment_setup_error' => __( 'To proceed, add a payment method to make future purchases on your accounts.', 'facebook-for-woocommerce' ), + 'onboarding_incomplete_error' => __( 'Whatsapp Business Account Onboarding is not complete or has failed.', 'facebook-for-woocommerce' ), + 'generic_error' => __( 'Something went wrong. Please try again.', 'facebook-for-woocommerce' ), + ), + ) + ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-consent-remove', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-consent-remove.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-consent-remove', + 'facebook_for_woocommerce_whatsapp_consent_remove', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-consent-disable-nonce' ), + 'i18n' => array( + 'result' => true, + ), + ) + ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-templates', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-templates.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-templates', + 'facebook_for_woocommerce_whatsapp_templates', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-templates-nonce' ), + 'i18n' => array( + 'result' => true, + ), + ) + ); + wp_enqueue_script( + 'facebook-for-woocommerce-whatsapp-disconnect', + facebook_for_woocommerce()->get_asset_build_dir_url() . '/admin/whatsapp-disconnect.js', + array( 'jquery', 'jquery-blockui', 'jquery-tiptip', 'wc-enhanced-select' ), + \WC_Facebookcommerce::PLUGIN_VERSION + ); + wp_localize_script( + 'facebook-for-woocommerce-whatsapp-disconnect', + 'facebook_for_woocommerce_whatsapp_disconnect', + array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'facebook-for-wc-whatsapp-disconnect-nonce' ), + 'i18n' => array( + 'result' => true, + ), + ) + ); + } + + + /** + * Renders the screen. + * + * @since 2.0.0 + */ + public function render() { + $view = $this->get_current_view(); + if ( 'utility_settings' === $view ) { + $this->render_utility_message_overview(); + } elseif ( in_array( $view, self::MANAGE_EVENT_VIEWS, true ) ) { + $this->render_manage_events_view(); + } else { + $this->render_utility_message_onboarding(); + } + parent::render(); + } + + /** + * Renders the WhatsApp Utility Onboarding screen. + */ + public function render_utility_message_onboarding() { + + ?> + +
+
+
+

+ +
+
+
+
+
+
+
+
+

+

+
+
+
+ +
+
+
+
+
+ + + +
+

+ +
+
+ +
+
+
+
+
+
+
+
+

+
+

+ + +

+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+

+

+ +

+
+
+
+
+
+
+

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+

+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+

+ +
+ + + + + +
+ + +
+
+
+

+
+ +
+ +
+
+
+
+
+
+
+

+
+ +
+
+
+ +
+
+

+
+ +
+ +
+
+
+ get_current_event(); + ?> +
+
+
+

+ + + + + + + +

+

+ + + + + + + +

+
+
+
+
+

+ +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+ +
+ 'order_management_4', + 'ORDER_FULFILLED' => 'shipment_confirmation_4', + 'ORDER_REFUNDED' => 'refund_confirmation_1', + ); + + /** @var string Default language for Library Template */ + const DEFAULT_LANGUAGE = 'en'; + + /** + * Makes an API call to Template Library API + * + * @param string $event Order Management Event + * @param string $bisu_token the BISU token received in the webhook + */ + public static function get_template_library_content( $event, $bisu_token ) { + wc_get_logger()->info( + sprintf( + __( 'In Template Library Get API call ', 'facebook-for-woocommerce' ), + ) + ); + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, 'message_template_library' ); + $base_url = esc_url( implode( '/', $base_url ) ); + $library_name = self::EVENT_TO_LIBRARY_TEMPLATE_MAPPING[ $event ]; + + $params = array( + 'name' => $library_name, + 'language' => self::DEFAULT_LANGUAGE, + 'access_token' => $bisu_token, + ); + $url = add_query_arg( $params, $base_url ); + $options = array( + 'headers' => array( + 'Authorization' => $bisu_token, + ), + 'body' => array(), + ); + + $response = wp_remote_request( $url, $options ); + $status_code = wp_remote_retrieve_response_code( $response ); + $data = wp_remote_retrieve_body( $response ); + if ( is_wp_error( $response ) || 200 !== $status_code ) { + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Template Library GET API call Failed %1$s ', 'facebook-for-woocommerce' ), + $data, + ) + ); + wp_send_json_error( $response, 'Template Library GET API call Failed' ); + } else { + wc_get_logger()->info( + sprintf( + __( 'Template Library GET API call Succeeded', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_success( $data, 'Finish Template Library API Call' ); + } + } + + /** + * Makes an API call to Whatsapp Utility Message Connect API + * + * @param string $waba_id WABA ID + * @param string $wacs_id WACS ID + * @param string $external_business_id external business ID + * @param string $bisu_token BISU token + */ + public static function wc_facebook_whatsapp_connect_utility_messages_call( $waba_id, $wacs_id, $external_business_id, $bisu_token ) { + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, $waba_id, 'connect_utility_messages' ); + $base_url = esc_url( implode( '/', $base_url ) ); + $query_params = array( + 'external_integration_id' => $external_business_id, + 'wacs_id' => $wacs_id, + 'access_token' => $bisu_token, + ); + $base_url = add_query_arg( $query_params, $base_url ); + $options = array( + 'headers' => array( + 'Authorization' => $bisu_token, + ), + 'body' => array(), + ); + $response = wp_remote_post( $base_url, $options ); + wc_get_logger()->info( + sprintf( + /* translators: %s $response */ + __( 'Connect Whatsapp Utility Message API Response: %1$s ', 'facebook-for-woocommerce' ), + json_encode( $response ), + ) + ); + $response_body = explode( "\n", wp_remote_retrieve_body( $response ) ); + $response_data = json_decode( $response_body[0] ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + $error_message = $response_data->error->error_user_title ?? $response_data->error->message ?? 'Something went wrong. Please try again later!'; + + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Connect Whatsapp Utility Message API Call Failure %1$s ', 'facebook-for-woocommerce' ), + $error_message, + ) + ); + wp_send_json_error( $error_message, 'Finish Onboarding Failure' ); + } else { + $integration_config_id = $response_data->id; + wc_get_logger()->info( + sprintf( + /* translators: %s $integration_config_id */ + __( 'Connect Whatsapp Utility Message API Call Success!!! Integration ID: %1$s!!!', 'facebook-for-woocommerce' ), + $integration_config_id, + ) + ); + update_option( 'wc_facebook_wa_integration_config_id', $integration_config_id ); + wp_send_json_success( $response, 'Finish Onboarding Success' ); + } + } + + /** + * Makes an API call to Whatsapp Utility Message Disconnect API and delete the options in DB + * + * @param string $waba_id WABA ID + * @param string $integration_config_id whatsapp integration config ID + * @param string $bisu_token BISU token + */ + public static function wc_facebook_disconnect_whatsapp( $waba_id, $integration_config_id, $bisu_token ) { + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, $waba_id, 'disconnect_utility_messages' ); + $base_url = esc_url( implode( '/', $base_url ) ); + $query_params = array( + 'integration_config_id' => $integration_config_id, + 'access_token' => $bisu_token, + ); + $base_url = add_query_arg( $query_params, $base_url ); + $options = array( + 'headers' => array( + 'Authorization' => $bisu_token, + ), + 'body' => array(), + ); + $response = wp_remote_post( $base_url, $options ); + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Disconnect Whatsapp Utility Message API Call Response: %1$s ', 'facebook-for-woocommerce' ), + json_encode( $response ), + ) + ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + $error_data = explode( "\n", wp_remote_retrieve_body( $response ) ); + $error_object = json_decode( $error_data[0] ); + $error_message = $error_object->error->error_user_title ?? $error_object->error->message ?? 'Something went wrong. Please try again later!'; + + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Disconnect Whatsapp Utility Message API Call Error: %1$s ', 'facebook-for-woocommerce' ), + $error_message, + ) + ); + wp_send_json_error( $error_message, 'Disconnect Whatsapp Failure' ); + } else { + wc_get_logger()->info( + sprintf( + __( 'Disconnect Whatsapp Utility Message API Call Success!!!', 'facebook-for-woocommerce' ) + ) + ); + + // delete all the whatsapp setting options in DB + $wa_settings = array( + 'wc_facebook_wa_integration_waba_id', + 'wc_facebook_wa_integration_bisu_access_token', + 'wc_facebook_wa_integration_business_id', + 'wc_facebook_wa_integration_wacs_phone_number', + 'wc_facebook_wa_integration_is_payment_setup', + 'wc_facebook_wa_integration_wacs_id', + 'wc_facebook_wa_integration_waba_profile_picture_url', + 'wc_facebook_wa_integration_waba_display_name', + 'wc_facebook_whatsapp_consent_collection_setting_status', + 'wc_facebook_wa_integration_config_id', + 'wc_facebook_wa_order_placed_event_config_id', + 'wc_facebook_wa_order_placed_language', + 'wc_facebook_wa_order_fulfilled_event_config_id', + 'wc_facebook_wa_order_fulfilled_language', + 'wc_facebook_wa_order_refunded_event_config_id', + 'wc_facebook_wa_order_refunded_language', + ); + + self::wc_facebook_whatsapp_settings_delete( $wa_settings ); + + wc_get_logger()->info( + sprintf( + __( 'Disconnect Whatsapp Utility Message - Whatsapp Settings Deletion Success!!!', 'facebook-for-woocommerce' ) + ) + ); + + wp_send_json_success( $response, 'Disconnect Whatsapp Success' ); + } + } + + public static function wc_facebook_whatsapp_settings_delete( $wa_settings ) { + foreach ( $wa_settings as $setting ) { + delete_option( $setting ); // this only deletes if option exists, no error on failure + } + } + + /** + * Makes an API call to Whatsapp Utility Event Configs Post API to create or update Event Configs + * + * @param string $event Order Management Event + * @param string $integration_config_id Integration Config Id + * @param string $language Language Code + * @param string $status ACTIVE or INACTIVE + * @param string $bisu_token the BISU token received in the webhook + */ + public static function post_whatsapp_utility_messages_event_configs_call( $event, $integration_config_id, $language, $status, $bisu_token ) { + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, $integration_config_id, 'event_configs' ); + $base_url = esc_url( implode( '/', $base_url ) ); + $account_url = get_permalink( get_option( 'woocommerce_myaccount_page_id' ) ); + $view_orders_endpoint = get_option( 'woocommerce_myaccount_view_order_endpoint' ); + $view_orders_base_url = esc_url( $account_url . $view_orders_endpoint ); + // Order Refunded template has no CTA + $library_template_button_inputs = 'ORDER_REFUNDED' === $event ? array() : array( + array( + 'type' => 'URL', + 'url' => array( + // View Url is dynamic and has order_id as suffix + 'base_url' => "$view_orders_base_url/{{1}}", + // Example view orders url with order id: 1234 + 'url_suffix_example' => "$view_orders_base_url/1234", + ), + ), + ); + $query_params = array( + 'event' => $event, + 'language' => $language, + 'status' => $status, + 'library_template_name' => self::EVENT_TO_LIBRARY_TEMPLATE_MAPPING[ $event ], + 'library_template_button_inputs' => $library_template_button_inputs, + 'access_token' => $bisu_token, + ); + $base_url = add_query_arg( $query_params, $base_url ); + $options = array( + 'headers' => array( + 'Authorization' => $bisu_token, + ), + 'body' => array(), + 'timeout' => 300, // 5 minutes + ); + $response = wp_remote_post( $base_url, $options ); + $status_code = wp_remote_retrieve_response_code( $response ); + $data = explode( "\n", wp_remote_retrieve_body( $response ) ); + $response_object = json_decode( $data[0] ); + $is_error = is_wp_error( $response ); + if ( is_wp_error( $response ) || 200 !== $status_code ) { + $error_message = $response_object->error->error_user_title ?? $response_object->error->message ?? 'Something went wrong. Please try again later!'; + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message %s status code %s is_wp_error value*/ + __( 'Event Configs Post API call Failed with Error: %1$s, Status code: %2$d, Is Wp Error: %3$s', 'facebook-for-woocommerce' ), + $error_message, + $status_code, + (string) $is_error, + ) + ); + wp_send_json_error( $response, 'Event Configs Post API call Failed' ); + } else { + $event_config_id_option_name = implode( '_', array( self::WA_UTILITY_OPTION_PREFIX, strtolower( $event ), 'event_config_id' ) ); + $event_config_language_option_name = implode( '_', array( self::WA_UTILITY_OPTION_PREFIX, strtolower( $event ), 'language' ) ); + $event_config_id = $response_object->id; + $event_status = $response_object->status; + $language = $response_object->language; + wc_get_logger()->info( + sprintf( + /* translators: %s $option_name %s $event_config_id %s $event_status */ + __( 'Event Configs Post API call Succeeded. API Response Event Config id: %1$s, Event Status: %2$s, Language: %3$s', 'facebook-for-woocommerce' ), + $event_config_id, + $event_status, + $language, + ) + ); + if ( 'ACTIVE' === $event_status ) { + update_option( $event_config_id_option_name, $event_config_id ); + update_option( $event_config_language_option_name, $language ); + } else { + $settings = array( + $event_config_id_option_name, + $event_config_language_option_name, + ); + self::wc_facebook_whatsapp_settings_delete( + $settings + ); + } + wp_send_json_success( 'Event Configs Post API call Completed' ); + } + } + + + /** + * Makes an API call to Event Processor: Message Events Post API to send whatsapp utility messages + * TODO: Update API Endpoint from Messages to Message Events + * + * @param string $event Order Managerment event + * @param string $event_config_id Event Config Id + * @param string $language_code Language code + * @param string $wacs_id Whatsapp Phone Number id + * @param string $order_id Order id + * @param string $phone_number Customer phone number + * @param string $first_name Customer first name + * @param int $refund_value Amount refunded to the Customer + * @param string $currency Currency code + * @param string $bisu_token the BISU token received in the webhook + */ + public static function post_whatsapp_utility_messages_events_call( $event, $event_config_id, $language_code, $wacs_id, $order_id, $phone_number, $first_name, $refund_value, $currency, $bisu_token ) { + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, $wacs_id, "messages?access_token=$bisu_token" ); + $base_url = esc_url( implode( '/', $base_url ) ); + $name = self::EVENT_TO_LIBRARY_TEMPLATE_MAPPING[ $event ]; + $components = self::get_components_for_event( $event, $order_id, $first_name, $refund_value, $currency ); + $options = array( + 'body' => array( + 'messaging_product' => 'whatsapp', + 'to' => $phone_number, + 'template' => array( + 'name' => $name, + 'language' => array( + 'code' => $language_code, + ), + 'components' => $components, + ), + 'type' => 'template', + ), + ); + $response = wp_remote_post( $base_url, $options ); + $status_code = wp_remote_retrieve_response_code( $response ); + $data = explode( "\n", wp_remote_retrieve_body( $response ) ); + $response_object = json_decode( $data[0] ); + if ( is_wp_error( $response ) || 200 !== $status_code ) { + $error_message = $response_object->error->error_user_title ?? $response_object->error->message ?? 'Something went wrong. Please try again later!'; + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id %s $error_message */ + __( 'Messages Post API call for Order id %1$s Failed %2$s ', 'facebook-for-woocommerce' ), + $order_id, + $error_message, + ) + ); + } else { + wc_get_logger()->info( + sprintf( + /* translators: %s $order_id */ + __( 'Messages Post API call for Order id %1$s Succeeded.', 'facebook-for-woocommerce' ), + $order_id + ) + ); + } + } + + + /** + * Gets Component Objects for Order Management Events + * + * @param string $event Order Management event + * @param string $order_id Order id + * @param string $first_name Customer first name + * @param string $refund_value Amount refunded to the Customer + * @param string $currency Currency code + */ + public static function get_components_for_event( $event, $order_id, $first_name, $refund_value, $currency ) { + if ( 'ORDER_REFUNDED' === $event ) { + return array( + array( + 'type' => 'HEADER', + 'parameters' => array( + array( + 'type' => 'currency', + 'currency' => array( + 'fallback_value' => 'VALUE', + 'code' => $currency, + 'amount_1000' => $refund_value, + ), + ), + ), + ), + array( + 'type' => 'BODY', + 'parameters' => array( + array( + 'type' => 'text', + 'text' => $first_name, + ), + array( + 'type' => 'currency', + 'currency' => array( + 'fallback_value' => 'VALUE', + 'code' => $currency, + 'amount_1000' => $refund_value, + ), + ), + array( + 'type' => 'text', + 'text' => "#$order_id", + ), + ), + ), + ); + } else { + return array( + array( + 'type' => 'BODY', + 'parameters' => array( + array( + 'type' => 'text', + 'text' => $first_name, + ), + array( + 'type' => 'text', + 'text' => "#$order_id", + ), + ), + ), + array( + 'type' => 'BUTTON', + 'sub_type' => 'url', + 'index' => 0, + 'parameters' => array( + array( + 'type' => 'text', + 'text' => "$order_id", + ), + ), + ), + ); + } + } +} diff --git a/includes/Handlers/Whatsapp_Webhook.php b/includes/Handlers/Whatsapp_Webhook.php new file mode 100644 index 000000000..12d9605ef --- /dev/null +++ b/includes/Handlers/Whatsapp_Webhook.php @@ -0,0 +1,209 @@ + array( 'POST' ), + 'callback' => array( $this, 'whatsapp_webhook_callback' ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * Updates Facebook settings options. + * + * @param array $settings Array of settings to update. + * + * @return bool + * @internal + */ + private static function update_settings( $settings ) { + $updated = array(); + foreach ( $settings as $key => $value ) { + if ( ! empty( $key ) ) { + $updated[ $key ] = update_option( $key, $value ); + } + } + // if any of setting updates failed, return false + return ! in_array( false, $updated, true ); + } + + /** + * Authenticates Whatsapp Webhook using the SHA1 of the external business ID and BISU token + * + * @param string $auth_key the auth key received in the webhook + * @param string $bisu_token the BISU token received in the webhook + * + * @return bool + * @internal + */ + private static function authenticate_request( $auth_key, $bisu_token ) { + $external_business_id = get_option( 'wc_facebook_external_business_id' ); + + $expected_auth_key = 'sha1=' . (string) hash_hmac( 'sha1', $bisu_token, $external_business_id ); + + return hash_equals( $expected_auth_key, $auth_key ); + } + + + + /** + * Whatsapp Webhook Listener + * + * @since 2.3.0 + * @see Connection + * + * @param \WP_REST_Request $request The request. + * @return \WP_REST_Response + */ + public function whatsapp_webhook_callback( \WP_REST_Request $request ) { + try { + $request_params = $request->get_params(); + $waba_id = sanitize_text_field( $request_params['wabaId'] ); + $wacs_id = sanitize_text_field( $request_params['wacsId'] ); + $is_waba_payment_setup = sanitize_text_field( $request_params['isWabaPaymentSetup'] ); + $waba_profile_picture_url = sanitize_text_field( $request_params['wabaProfilePictureUrl'] ); + $bisu_token = sanitize_text_field( $request_params['clientBisuToken'] ); + $business_id = sanitize_text_field( $request_params['clientBusinessId'] ); + $wacs_phone_number = sanitize_text_field( $request_params['wacsPhoneNumber'] ); + $waba_display_name = sanitize_text_field( $request_params['wabaDisplayName'] ); + $auth_key = sanitize_text_field( $request_params['authKey'] ); + + // authentication is done via auth_key using sha_1 hash mac of BISU token and external business ID stored in woo DB + $authentication_result = self::authenticate_request( $auth_key, $bisu_token ); + + if ( false === $authentication_result ) { + wc_get_logger()->info( + sprintf( + __( 'Authentication Failure on received Whatsapp Webhook', 'facebook-for-woocommerce' ), + ) + ); + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => 'Authentication Failure on received Whatsapp Webhook', + ], + 400 + ); + } + + if ( empty( $waba_id ) || empty( $bisu_token ) || empty( $business_id ) || empty( $wacs_phone_number ) || empty( $wacs_id ) ) { + wc_get_logger()->info( + sprintf( + __( 'All required onboarding info not received in Whatsapp Webhook', 'facebook-for-woocommerce' ), + ) + ); + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => 'All required onboarding info not received in Whatsapp Webhook', + ], + 400 + ); + } + + wc_get_logger()->info( + sprintf( + /* translators: %s waba ID %s business ID */ + __( 'Whatsapp Account WebHook Event received. WABA ID: %1$s, Business ID: %2$s ', 'facebook-for-woocommerce' ), + $waba_id, + $business_id + ) + ); + + $options_setting_fields = array( + 'wc_facebook_wa_integration_waba_id' => $waba_id, + 'wc_facebook_wa_integration_bisu_access_token' => $bisu_token, + 'wc_facebook_wa_integration_business_id' => $business_id, + 'wc_facebook_wa_integration_wacs_phone_number' => $wacs_phone_number, + 'wc_facebook_wa_integration_is_payment_setup' => $is_waba_payment_setup, + 'wc_facebook_wa_integration_wacs_id' => $wacs_id, + 'wc_facebook_wa_integration_waba_profile_picture_url' => $waba_profile_picture_url, + 'wc_facebook_wa_integration_waba_display_name' => $waba_display_name, + + ); + + $result = self::update_settings( $options_setting_fields ); + + if ( false === $result ) { + wc_get_logger()->info( + sprintf( + /* translators: %d $waba_id, %d $business_id. */ + __( 'Whatsapp Integration Setting Fields Update Failure waba_id: %1$s, business_id: %2$s', 'facebook-for-woocommerce' ), + $waba_id, + $business_id, + ) + ); + + return new \WP_REST_Response( + [ + 'success' => false, + 'message' => 'Whatsapp Integration Setting Fields Update Failure', + ], + 400 + ); + + } + + wc_get_logger()->info( + sprintf( + /* translators: %d $waba_id, %d $business_id. */ + __( 'Whatsapp Integration Setting Fields stored successfully in wp_options. wc_facebook_wa_integration_waba_id: %1$s, wc_facebook_wa_integration_business_id: %2$s ', 'facebook-for-woocommerce' ), + $waba_id, + $business_id, + ) + ); + + return new \WP_REST_Response( [ 'success' => true ], 200 ); + } catch ( \Exception $e ) { + return $this->error_response( + [ + 'success' => false, + 'message' => $e->getMessage(), + ], + 500 + ); + } + } +} diff --git a/includes/RolloutSwitches.php b/includes/RolloutSwitches.php index e9beb84dc..1c925c269 100644 --- a/includes/RolloutSwitches.php +++ b/includes/RolloutSwitches.php @@ -23,10 +23,12 @@ class RolloutSwitches { /** @var \WC_Facebookcommerce commerce handler */ private \WC_Facebookcommerce $plugin; - public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; + public const SWITCH_ROLLOUT_FEATURES = 'rollout_enabled'; + public const WHATSAPP_UTILITY_MESSAGING = 'whatsapp_utility_messages_enabled'; private const ACTIVE_SWITCHES = array( self::SWITCH_ROLLOUT_FEATURES, + self::WHATSAPP_UTILITY_MESSAGING, ); /** * Stores the rollout switches and their enabled/disabled states. @@ -88,7 +90,7 @@ public function is_switch_enabled( string $switch_name ) { return false; } - return $this->rollout_switches[ $switch_name ] ?? true; + return isset( $this->rollout_switches[ $switch_name ] ) ? $this->rollout_switches[ $switch_name ] : true; } public function is_switch_active( string $switch_name ): bool { diff --git a/webpack.config.js b/webpack.config.js index 080f4ae1c..3949fc7e7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,23 +12,31 @@ const jQueryUIAdminFileNames = [ 'products-admin', 'settings-commerce', 'settings-sync', + 'whatsapp-billing', + 'whatsapp-connection', + 'whatsapp-consent', + 'whatsapp-templates', + 'whatsapp-finish', + 'whatsapp-consent-remove', + 'whatsapp-disconnect', + 'whatsapp-events', ]; const jQueryUIAdminFileEntries = {}; jQueryUIAdminFileNames.forEach( ( name ) => { - jQueryUIAdminFileEntries[ `admin/${ name }` ] = `./assets/js/admin/${ name }.js`; + jQueryUIAdminFileEntries[ `admin/${ name }` ] = `./assets/js/admin/${ name }.js`; } ); module.exports = { - ...defaultConfig, - entry: { - // Use admin/index.js for any new React-powered UI - 'admin/index': './assets/js/admin/index.js', - ...jQueryUIAdminFileEntries, - }, - output: { - filename: '[name].js', - path: __dirname + '/assets/build', - }, + ...defaultConfig, + entry: { + // Use admin/index.js for any new React-powered UI + 'admin/index': './assets/js/admin/index.js', + ...jQueryUIAdminFileEntries, + }, + output: { + filename: '[name].js', + path: __dirname + '/assets/build', + }, }; From a3ef4a6f3b454f6c09ff0bb1eb8efa7bfda4853b Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Fri, 14 Mar 2025 10:21:42 -0700 Subject: [PATCH 05/28] Improve Test Filter Management (#2944) Summary: This PR addresses a common source of test flakiness by implementing proper WordPress filter management in our test suite. Sometimes when testing, you want to override the behavior of a global function. This can be done using a wordpress primitive called `add_filter` While this can be helpful for mocking deep functionality, these filters present a challenge when testing across hundreds of files: 1. **Filter Leakage**: When `add_filter()` is used in tests without proper cleanup, filters persist between tests, causing unexpected behavior and interdependencies. 2. **Incomplete Cleanup**: Manual filter removal is error-prone and often forgotten, especially when tests fail before reaching teardown code. 3. **Overly Aggressive Cleanup**: Using `remove_all_filters()` is dangerous as it removes ALL filters for a hook, including those added by WordPress core or other plugins, potentially breaking functionality in subsequent tests. The `AbstractWPUnitTestWithSafeFiltering` class provides: - A `add_filter_with_safe_teardown()` method that tracks all added filters - Automatic cleanup in `tearDown()` that only removes filters added by the current test - The ability to remove specific filters early with `teardown_safely_immediately()` - A category-specific cleanup method with `teardown_callback_category_safely()` -- allowing you to safely wipe out all test-related filters for 'event_type_A' without affecting B or C. - Introduced AbstractWPUnitTestWithSafeFiltering with above functionality. - Updated WCFacebookCommerceIntegrationTest, fbproductTest, WPMLTest, and FeedUploadUtilsTest to extend AbstractWPUnitTestWithSafeFiltering - Replaced all `add_filter()` calls with `add_filter_with_safe_teardown()` - in some special cases (per-test), replaced remove_all_filters with appropriate teardown calls - In most cases, rely on default "invisible" safe teardown behavior - Ran updated test suit using phpunit -- all succeeded. Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/2944 Test Plan: Imported from GitHub, without a `Test Plan:` line. **!---- (auto-generated) DO NOT EDIT OR PUT ANYTHING AFTER THIS LINE ----!** MFTRunTestsScript Run / Test Suite: sa_checkout / Test Collection: bloks / Diff Version V1 https://internalfb.com/intern/testinfra/testrun/1970325109964811 MFTRunTestsScript Run / Test Suite: sa_checkout / Test Collection: www / Diff Version V1 https://internalfb.com/intern/testinfra/testrun/3659174967893694 Reviewed By: carterbuce Differential Revision: D71158932 Pulled By: sol-loup fbshipit-source-id: 8b3479374bb836dd74a742905226b285da2bfe66 --- .../AbstractWPUnitTestWithSafeFiltering.php | 125 ++++++++++++++++++ tests/Unit/ApiTest.php | 46 +++---- .../WCFacebookCommerceIntegrationTest.php | 85 ++++++------ tests/Unit/WPMLTest.php | 15 ++- tests/Unit/fbproductTest.php | 15 ++- 5 files changed, 207 insertions(+), 79 deletions(-) create mode 100644 tests/Unit/AbstractWPUnitTestWithSafeFiltering.php diff --git a/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php b/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php new file mode 100644 index 000000000..fc199a942 --- /dev/null +++ b/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php @@ -0,0 +1,125 @@ +filter_callbacks = []; + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + // Remove specific filters that were added by this test + foreach ($this->filter_callbacks as $hook => $callbacks) { + foreach ($callbacks as $callback_data) { + remove_filter($hook, $callback_data['callback'], $callback_data['priority']); + } + } + + parent::tearDown(); + } + + /** + * Helper method to remove all filters for a specific hook safely + * + * @param string $hook The filter hook name to remove all callbacks for + * @return void + */ + protected function teardown_callback_category_safely($hook) { + if (isset($this->filter_callbacks[$hook])) { + foreach ($this->filter_callbacks[$hook] as $callback_data) { + remove_filter($hook, $callback_data['callback'], $callback_data['priority']); + } + // Clear the tracking for this hook + unset($this->filter_callbacks[$hook]); + } + } + + /** + * Helper method to add a filter and store its callback for later removal + * + * @param string $hook The filter hook name + * @param callable $callback The filter callback function + * @param int $priority The priority of the filter + * @param int $accepted_args The number of arguments the function accepts + * @return object A simple object with remove() method for easy cleanup + */ + protected function add_filter_with_safe_teardown($hook, $callback, $priority = 10, $accepted_args = 1) { + add_filter($hook, $callback, $priority, $accepted_args); + + if (!isset($this->filter_callbacks[$hook])) { + $this->filter_callbacks[$hook] = []; + } + + $this->filter_callbacks[$hook][] = [ + 'callback' => $callback, + 'priority' => $priority + ]; + + $self = $this; + + // Return a simple object with a remove method + return new class($hook, $callback, $priority, $self) { + private $hook; + private $callback; + private $priority; + private $test_case; + + public function __construct($hook, $callback, $priority, $test_case) { + $this->hook = $hook; + $this->callback = $callback; + $this->priority = $priority; + $this->test_case = $test_case; + } + + public function teardown_safely_immediately() { + remove_filter($this->hook, $this->callback, $this->priority); + $this->test_case->removeFilterFromTracking($this->hook, $this->callback, $this->priority); + } + }; + } + + /** + * Remove a filter from the tracking array + * + * @param string $hook The filter hook name + * @param callable $callback The filter callback function + * @param int $priority The priority of the filter + */ + public function removeFilterFromTracking($hook, $callback, $priority) { + if (isset($this->filter_callbacks[$hook])) { + foreach ($this->filter_callbacks[$hook] as $key => $callback_data) { + if ($callback_data['callback'] === $callback && $callback_data['priority'] === $priority) { + unset($this->filter_callbacks[$hook][$key]); + break; + } + } + + // Clean up empty arrays + if (empty($this->filter_callbacks[$hook])) { + unset($this->filter_callbacks[$hook]); + } + } + } +} diff --git a/tests/Unit/ApiTest.php b/tests/Unit/ApiTest.php index d10c2f936..131acb9a7 100644 --- a/tests/Unit/ApiTest.php +++ b/tests/Unit/ApiTest.php @@ -7,7 +7,7 @@ /** * Api unit test clas. */ -class ApiTest extends WP_UnitTestCase { +class ApiTest extends \WooCommerce\Facebook\Tests\Unit\AbstractWPUnitTestWithSafeFiltering { /** * Facebook Graph API endpoint. @@ -54,7 +54,7 @@ public function test_perform_request_performs_successful_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_catalog( '726635365295186' ); @@ -78,7 +78,7 @@ public function test_perform_request_produces_wp_error() { $this->assertEquals( "{$this->endpoint}{$this->version}/726635365295186?fields=name", $url ); return new WP_Error( 007, 'WP Error Message' ); }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $this->api->get_catalog( '726635365295186' ); } @@ -103,7 +103,7 @@ public function test_get_installation_ids_returns_installation_ids_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_installation_ids( $external_business_id ); @@ -130,7 +130,7 @@ public function test_get_catalog_returns_catalog_id_and_name_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_catalog( $catalog_id ); @@ -158,7 +158,7 @@ public function test_get_user_returns_user_information_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_user( $user_id ); @@ -187,7 +187,7 @@ public function test_delete_mbe_connection_deletes_mbe_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->delete_mbe_connection( $external_business_id ); @@ -214,7 +214,7 @@ public function test_get_business_configuration_returns_business_configuration_r ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_business_configuration( $external_business_id ); @@ -267,7 +267,7 @@ public function test_send_item_updates_sends_item_updates_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->send_item_updates( $facebook_catalog_id, $requests ); @@ -313,7 +313,7 @@ public function test_create_product_group_performs_create_product_group_request( ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->create_product_group( $facebook_product_catalog_id, $data ); @@ -355,7 +355,7 @@ public function test_update_product_group_preforms_update_product_group_request( ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->update_product_group( $facebook_product_group_id, $data ); @@ -382,7 +382,7 @@ public function test_delete_product_group_deletes_product_group_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->delete_product_group( $facebook_product_group_id ); @@ -410,7 +410,7 @@ public function test_get_product_group_products_returns_group_products_request() ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_product_group_products( $facebook_product_group_id, $limit ); @@ -473,7 +473,7 @@ public function test_create_product_item_creates_product_item_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->create_product_item( $facebook_product_group_id, $data ); @@ -520,7 +520,7 @@ public function test_update_product_item_updated_product_item_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->update_product_item( $facebook_product_id, $data ); @@ -553,7 +553,7 @@ public function test_get_product_facebook_ids_creates_get_ids_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->get_product_facebook_ids( $facebook_product_catalog_id, $facebook_product_retailer_id ); @@ -668,7 +668,7 @@ public function test_delete_product_item_deletes_product_item_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->delete_product_item( $facebook_product_id ); @@ -701,7 +701,7 @@ public function test_create_product_set_item_creates_set_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->create_product_set_item( $facebook_product_catalog_id, $data ); @@ -734,7 +734,7 @@ public function test_update_product_set_item_updates_set_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->update_product_set_item( $facebook_product_set_id, $data ); @@ -761,7 +761,7 @@ public function test_delete_product_set_item_deletes_set_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->delete_product_set_item( $facebook_product_set_id, true ); @@ -788,7 +788,7 @@ public function test_read_feeds_creates_read_feeds_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->read_feeds( $facebook_product_catalog_id ); @@ -828,7 +828,7 @@ public function test_create_feed_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response_feed = $this->api->create_feed( $facebook_product_catalog_id, $data ); @@ -858,7 +858,7 @@ public function test_create_upload_request() { ], ]; }; - add_filter( 'pre_http_request', $response, 10, 3 ); + $this->add_filter_with_safe_teardown( 'pre_http_request', $response, 10, 3 ); $response = $this->api->create_upload( $product_feed_id, $data ); $this->assertFalse( $response->has_api_error() ); diff --git a/tests/Unit/WCFacebookCommerceIntegrationTest.php b/tests/Unit/WCFacebookCommerceIntegrationTest.php index 2757b33b6..3788d1fe4 100644 --- a/tests/Unit/WCFacebookCommerceIntegrationTest.php +++ b/tests/Unit/WCFacebookCommerceIntegrationTest.php @@ -16,7 +16,7 @@ /** * Unit tests for Facebook Graph API calls. */ -class WCFacebookCommerceIntegrationTest extends WP_UnitTestCase { +class WCFacebookCommerceIntegrationTest extends \WooCommerce\Facebook\Tests\Unit\AbstractWPUnitTestWithSafeFiltering { /** * @var WC_Facebookcommerce @@ -151,7 +151,7 @@ public function test_init_pixel_for_admin_user_must_init_pixel_overwrites_pixel_ self::$default_options ); - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_pixel_id', function ( $wc_facebook_pixel_id ) { return '998877665544332211'; @@ -189,7 +189,7 @@ public function test_init_pixel_for_admin_user_must_init_pixel_overwrites_use_pi self::$default_options ); - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_is_advanced_matching_enabled', function ( $use_pii ) { return false; @@ -329,7 +329,7 @@ public function test_get_variation_product_item_ids_from_facebook_with_fb_retail ->with( $facebook_product_group_id ) ->willReturn( $facebook_response ); - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_fb_retailer_id', function ( $retailer_id ) { return $retailer_id . '_modified'; @@ -368,7 +368,7 @@ public function test_get_product_count_returns_product_count_with_no_filters() { * @return void */ public function test_get_product_count_returns_product_count_with_filters() { - add_filter( + $this->add_filter_with_safe_teardown( 'wp_count_posts', function( $counts ) { $counts->publish = 21; @@ -402,7 +402,7 @@ public function test_allow_full_batch_api_sync_returns_default_allow_status_with * @return void */ public function test_allow_full_batch_api_sync_uses_block_full_batch_api_sync_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'facebook_for_woocommerce_block_full_batch_api_sync', function ( bool $status ) { return true; @@ -425,7 +425,7 @@ function ( bool $status ) { public function test_allow_full_batch_api_sync_uses_allow_full_batch_api_sync_filter() { $this->markTestSkipped( 'Some problems with phpunit polyfills notices handling.' ); - add_filter( + $this->add_filter_with_safe_teardown( 'facebook_for_woocommerce_allow_full_batch_api_sync', function ( bool $status ) { return false; @@ -556,7 +556,7 @@ public function test_on_product_save_existing_simple_product_sync_enabled_update // Verify Facebook-specific fields were saved $facebook_product_to_update = new WC_Facebook_Product( $product_to_update->get_id() ); $updated_product_data = $facebook_product_to_update->prepare_product(null, \WC_Facebook_Product::PRODUCT_PREP_TYPE_ITEMS_BATCH ); - + $this->assertEquals( 'Facebook product description.', get_post_meta( $facebook_product_to_update->get_id(), WC_Facebook_Product::FB_PRODUCT_DESCRIPTION, true ) ); $this->assertEquals( 'Facebook product description.', get_post_meta( $facebook_product_to_update->get_id(), WC_Facebook_Product::FB_RICH_TEXT_DESCRIPTION, true ) ); @@ -1953,7 +1953,7 @@ public function test_reset_single_product_for_variable_product() { */ public function test_get_product_catalog_id_returns_product_catalog_from_initialised_property_using_no_filter() { $this->integration->product_catalog_id = '123123123123123123'; - remove_all_filters( 'wc_facebook_product_catalog_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_product_catalog_id' ); $product_catalog_id = $this->integration->get_product_catalog_id(); @@ -1968,7 +1968,7 @@ public function test_get_product_catalog_id_returns_product_catalog_from_initial public function test_get_product_catalog_id_returns_product_catalog_from_options_using_no_filter() { $this->integration->product_catalog_id = null; add_option( WC_Facebookcommerce_Integration::OPTION_PRODUCT_CATALOG_ID, '321321321321321321' ); - remove_all_filters( 'wc_facebook_product_catalog_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_product_catalog_id' ); $product_catalog_id = $this->integration->get_product_catalog_id(); @@ -1982,7 +1982,7 @@ public function test_get_product_catalog_id_returns_product_catalog_from_options * @return void */ public function test_get_product_catalog_id_returns_product_catalog_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_product_catalog_id', function ( $product_catalog_id ) { return '3213-2132-1321-3213-2132'; @@ -2001,7 +2001,7 @@ function ( $product_catalog_id ) { */ public function test_get_external_merchant_settings_id_returns_settings_id_from_initialised_property_using_no_filter() { $this->integration->external_merchant_settings_id = '123123123123123123'; - remove_all_filters( 'wc_facebook_external_merchant_settings_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_external_merchant_settings_id' ); $external_merchant_settings_id = $this->integration->get_external_merchant_settings_id(); @@ -2016,7 +2016,7 @@ public function test_get_external_merchant_settings_id_returns_settings_id_from_ public function test_get_external_merchant_settings_id_returns_settings_id_from_options_using_no_filter() { $this->integration->external_merchant_settings_id = null; add_option( WC_Facebookcommerce_Integration::OPTION_EXTERNAL_MERCHANT_SETTINGS_ID, '321321321321321321' ); - remove_all_filters( 'wc_facebook_external_merchant_settings_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_external_merchant_settings_id' ); $external_merchant_settings_id = $this->integration->get_external_merchant_settings_id(); @@ -2030,7 +2030,7 @@ public function test_get_external_merchant_settings_id_returns_settings_id_from_ * @return void */ public function test_get_external_merchant_settings_id_returns_settings_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_external_merchant_settings_id', function ( $external_merchant_settings_id ) { return '3213-2132-1321-3213-2132'; @@ -2049,7 +2049,7 @@ function ( $external_merchant_settings_id ) { */ public function test_get_feed_id_returns_id_from_initialised_property_using_no_filter() { $this->integration->feed_id = '123123123123123123'; - remove_all_filters( 'wc_facebook_feed_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_feed_id' ); $feed_id = $this->integration->get_feed_id(); @@ -2064,7 +2064,7 @@ public function test_get_feed_id_returns_id_from_initialised_property_using_no_f public function test_get_feed_id_returns_id_from_options_using_no_filter() { $this->integration->feed_id = null; add_option( WC_Facebookcommerce_Integration::OPTION_FEED_ID, '321321321321321321' ); - remove_all_filters( 'wc_facebook_feed_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_feed_id' ); $feed_id = $this->integration->get_feed_id(); @@ -2078,7 +2078,7 @@ public function test_get_feed_id_returns_id_from_options_using_no_filter() { * @return void */ public function test_get_feed_id_returns_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_feed_id', function ( $feed_id ) { return '3213-2132-1321-3213-2132'; @@ -2097,7 +2097,7 @@ function ( $feed_id ) { */ public function test_get_upload_id_returns_id_from_options_using_no_filter() { add_option( WC_Facebookcommerce_Integration::OPTION_UPLOAD_ID, '321321321321321321' ); - remove_all_filters( 'wc_facebook_upload_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_upload_id' ); $upload_id = $this->integration->get_upload_id(); @@ -2110,7 +2110,7 @@ public function test_get_upload_id_returns_id_from_options_using_no_filter() { * @return void */ public function test_get_upload_id_returns_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_upload_id', function ( $upload_id ) { return '3213-2132-1321-3213-2132'; @@ -2129,7 +2129,7 @@ function ( $upload_id ) { */ public function test_get_pixel_install_time_returns_id_from_initialised_property_using_no_filter() { $this->integration->pixel_install_time = '123123123123123123'; - remove_all_filters( 'wc_facebook_pixel_install_time' ); + $this->teardown_callback_category_safely( 'wc_facebook_pixel_install_time' ); $pixel_install_time = $this->integration->get_pixel_install_time(); @@ -2144,7 +2144,7 @@ public function test_get_pixel_install_time_returns_id_from_initialised_property public function test_get_pixel_install_time_returns_id_from_options_using_no_filter() { $this->integration->pixel_install_time = null; add_option( WC_Facebookcommerce_Integration::OPTION_PIXEL_INSTALL_TIME, '321321321321321321' ); - remove_all_filters( 'wc_facebook_pixel_install_time' ); + $this->teardown_callback_category_safely( 'wc_facebook_pixel_install_time' ); $pixel_install_time = $this->integration->get_pixel_install_time(); @@ -2158,7 +2158,7 @@ public function test_get_pixel_install_time_returns_id_from_options_using_no_fil * @return void */ public function test_get_pixel_install_time_returns_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_pixel_install_time', function ( $pixel_install_time ) { return '321321321321'; @@ -2179,7 +2179,7 @@ public function test_get_js_sdk_version_returns_id_from_options_using_no_filter( $this->markTestSkipped( 'get_js_sdk_version method is called in constructor which makes it impossible to test it in isolation w/o refactoring the constructor.' ); add_option( WC_Facebookcommerce_Integration::OPTION_JS_SDK_VERSION, 'v1.0.0' ); - remove_all_filters( 'wc_facebook_js_sdk_version' ); + $this->teardown_callback_category_safely( 'wc_facebook_js_sdk_version' ); $js_sdk_version = $this->integration->get_js_sdk_version(); @@ -2194,7 +2194,7 @@ public function test_get_js_sdk_version_returns_id_from_options_using_no_filter( public function test_get_js_sdk_version_returns_id_with_filter() { $this->markTestSkipped( 'get_js_sdk_version method is called in constructor which makes it impossible to test it in isolation w/o refactoring the constructor.' ); - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_js_sdk_version', function ( $js_sdk_version ) { return 'v2.0.0'; @@ -2212,7 +2212,7 @@ function ( $js_sdk_version ) { * @return void */ public function test_get_facebook_page_id_no_filters() { - remove_all_filters( 'wc_facebook_page_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_page_id' ); add_option( WC_Facebookcommerce_Integration::SETTING_FACEBOOK_PAGE_ID, '222333111444555666777' ); $facebook_page_id = $this->integration->get_facebook_page_id(); @@ -2226,7 +2226,7 @@ public function test_get_facebook_page_id_no_filters() { * @return void */ public function test_get_facebook_page_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_page_id', function ( $facebook_page_id ) { return '444333222111999888777666555'; @@ -2244,7 +2244,7 @@ function ( $facebook_page_id ) { * @return void */ public function test_get_facebook_pixel_id_no_filters() { - remove_all_filters( 'wc_facebook_pixel_id' ); + $this->teardown_callback_category_safely( 'wc_facebook_pixel_id' ); add_option( WC_Facebookcommerce_Integration::SETTING_FACEBOOK_PIXEL_ID, '222333111444555666777' ); $facebook_pixel_id = $this->integration->get_facebook_pixel_id(); @@ -2258,7 +2258,7 @@ public function test_get_facebook_pixel_id_no_filters() { * @return void */ public function test_get_facebook_pixel_id_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_pixel_id', function ( $facebook_pixel_id ) { return '444333222111999888777666555'; @@ -2276,7 +2276,7 @@ function ( $facebook_pixel_id ) { * @return void */ public function test_get_excluded_product_category_ids_no_filter_no_option() { - remove_all_filters( 'wc_facebook_excluded_product_category_ids' ); + $this->teardown_callback_category_safely( 'wc_facebook_excluded_product_category_ids' ); delete_option( WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_CATEGORY_IDS ); $categories = $this->integration->get_excluded_product_category_ids(); @@ -2290,7 +2290,7 @@ public function test_get_excluded_product_category_ids_no_filter_no_option() { * @return void */ public function test_get_excluded_product_category_ids_no_filter() { - remove_all_filters( 'wc_facebook_excluded_product_category_ids' ); + $this->teardown_callback_category_safely( 'wc_facebook_excluded_product_category_ids' ); add_option( WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_CATEGORY_IDS, [ 121, 221, 321, 421, 521, 621 ] @@ -2307,7 +2307,7 @@ public function test_get_excluded_product_category_ids_no_filter() { * @return void */ public function test_get_excluded_product_category_ids_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_excluded_product_category_ids', function ( $ids ) { return [ 111, 222, 333 ]; @@ -2330,7 +2330,7 @@ function ( $ids ) { * @return void */ public function test_get_excluded_product_tag_ids_no_filter_no_option() { - remove_all_filters( 'wc_facebook_excluded_product_tag_ids' ); + $this->teardown_callback_category_safely( 'wc_facebook_excluded_product_tag_ids' ); delete_option( WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_TAG_IDS ); $tags = $this->integration->get_excluded_product_tag_ids(); @@ -2344,7 +2344,7 @@ public function test_get_excluded_product_tag_ids_no_filter_no_option() { * @return void */ public function test_get_excluded_product_tag_ids_no_filter() { - remove_all_filters( 'wc_facebook_excluded_product_tag_ids' ); + $this->teardown_callback_category_safely( 'wc_facebook_excluded_product_tag_ids' ); add_option( WC_Facebookcommerce_Integration::SETTING_EXCLUDED_PRODUCT_TAG_IDS, [ 121, 221, 321, 421, 521, 621 ] @@ -2361,7 +2361,7 @@ public function test_get_excluded_product_tag_ids_no_filter() { * @return void */ public function test_get_excluded_product_tag_ids_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_excluded_product_tag_ids', function ( $ids ) { return [ 111, 222, 333 ]; @@ -2379,6 +2379,7 @@ function ( $ids ) { } + /** * Tests product catalog id option update with valid catalog id value. * @@ -2595,7 +2596,7 @@ public function test_is_configured_returns_false_is_not_connected() { * @return void */ public function test_is_advanced_matching_enabled_no_filter() { - remove_all_filters( 'wc_facebook_is_advanced_matching_enabled' ); + $this->teardown_callback_category_safely( 'wc_facebook_is_advanced_matching_enabled' ); $output = $this->integration->is_advanced_matching_enabled(); @@ -2608,7 +2609,7 @@ public function test_is_advanced_matching_enabled_no_filter() { * @return void */ public function test_is_advanced_matching_enabled_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_is_advanced_matching_enabled', function ( $is_enabled ) { return false; @@ -2626,7 +2627,7 @@ function ( $is_enabled ) { * @return void */ public function test_is_product_sync_enabled_no_filter_no_option() { - remove_all_filters( 'wc_facebook_is_product_sync_enabled' ); + $this->teardown_callback_category_safely( 'wc_facebook_is_product_sync_enabled' ); delete_option( WC_Facebookcommerce_Integration::SETTING_ENABLE_PRODUCT_SYNC ); $result = $this->integration->is_product_sync_enabled(); @@ -2640,7 +2641,7 @@ public function test_is_product_sync_enabled_no_filter_no_option() { * @return void */ public function test_is_product_sync_enabled_no_filter() { - remove_all_filters( 'wc_facebook_is_product_sync_enabled' ); + $this->teardown_callback_category_safely( 'wc_facebook_is_product_sync_enabled' ); add_option( WC_Facebookcommerce_Integration::SETTING_ENABLE_PRODUCT_SYNC, 'no' @@ -2657,7 +2658,7 @@ public function test_is_product_sync_enabled_no_filter() { * @return void */ public function test_is_product_sync_enabled_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_is_product_sync_enabled', function ( $is_enabled ) { return false; @@ -2708,7 +2709,7 @@ public function test_is_legacy_feed_file_generation_enabled_with_option() { * @return void */ public function test_is_debug_mode_enabled_returns_default_value() { - remove_all_filters( 'wc_facebook_is_debug_mode_enabled' ); + $this->teardown_callback_category_safely( 'wc_facebook_is_debug_mode_enabled' ); delete_option( WC_Facebookcommerce_Integration::SETTING_ENABLE_DEBUG_MODE ); $result = $this->integration->is_debug_mode_enabled(); @@ -2722,7 +2723,7 @@ public function test_is_debug_mode_enabled_returns_default_value() { * @return void */ public function test_is_debug_mode_enabled_returns_option_value() { - remove_all_filters( 'wc_facebook_is_debug_mode_enabled' ); + $this->teardown_callback_category_safely( 'wc_facebook_is_debug_mode_enabled' ); add_option( WC_Facebookcommerce_Integration::SETTING_ENABLE_DEBUG_MODE, 'yes' @@ -2739,7 +2740,7 @@ public function test_is_debug_mode_enabled_returns_option_value() { * @return void */ public function test_is_debug_mode_enabled_with_filter() { - add_filter( + $this->add_filter_with_safe_teardown( 'wc_facebook_is_debug_mode_enabled', function ( $is_enabled ) { return false; diff --git a/tests/Unit/WPMLTest.php b/tests/Unit/WPMLTest.php index 436e70003..dd805d551 100644 --- a/tests/Unit/WPMLTest.php +++ b/tests/Unit/WPMLTest.php @@ -1,6 +1,6 @@ add_filter_with_safe_teardown( 'wpml_post_language_details', function() { return new WP_Error(); @@ -36,7 +37,7 @@ function() { public function test_product_hidden_no_settings_and_not_default() { WC_Facebook_WPML_Injector::$default_lang = 'en_US'; - add_filter( + $filter = $this->add_filter_with_safe_teardown( 'wpml_post_language_details', function() { return [ @@ -50,7 +51,7 @@ function() { public function test_product_not_hidden_no_settings_and_default() { WC_Facebook_WPML_Injector::$default_lang = 'en_US'; - add_filter( + $filter = $this->add_filter_with_safe_teardown( 'wpml_post_language_details', function() { return [ @@ -67,7 +68,7 @@ public function test_product_hidden_language_setting_not_visible() { 'fr_FR' => FB_WPML_Language_Status::HIDDEN, ]; - add_filter( + $filter = $this->add_filter_with_safe_teardown( 'wpml_post_language_details', function() { return [ @@ -84,7 +85,7 @@ public function test_product_not_hidden_language_setting_visible() { 'fr_FR' => FB_WPML_Language_Status::VISIBLE, ]; - add_filter( + $filter = $this->add_filter_with_safe_teardown( 'wpml_post_language_details', function() { return [ diff --git a/tests/Unit/fbproductTest.php b/tests/Unit/fbproductTest.php index a2637ec70..945ad2796 100644 --- a/tests/Unit/fbproductTest.php +++ b/tests/Unit/fbproductTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -class fbproductTest extends WP_UnitTestCase { +class fbproductTest extends \WooCommerce\Facebook\Tests\Unit\AbstractWPUnitTestWithSafeFiltering { private $parent_fb_product; /** @var \WC_Product_Simple */ @@ -104,18 +104,18 @@ public function test_filter_fb_description() { $facebook_product = new \WC_Facebook_Product( $product ); $facebook_product->set_description( 'fb description' ); - add_filter( 'facebook_for_woocommerce_fb_product_description', function( $description ) { + $filter = $this->add_filter_with_safe_teardown( 'facebook_for_woocommerce_fb_product_description', function( $description ) { return 'filtered description'; }); $description = $facebook_product->get_fb_description(); $this->assertEquals( $description, 'filtered description' ); - remove_all_filters( 'facebook_for_woocommerce_fb_product_description' ); + // Remove the filter early + $filter->teardown_safely_immediately(); $description = $facebook_product->get_fb_description(); $this->assertEquals( $description, 'fb description' ); - } /** @@ -653,15 +653,16 @@ public function test_get_rich_text_description() { $this->assertEquals('

short description test

', $description); // Test 7: Applies filters - add_filter('facebook_for_woocommerce_fb_rich_text_description', function($description) { + $filter = $this->add_filter_with_safe_teardown('facebook_for_woocommerce_fb_rich_text_description', function($description) { return '

filtered description

'; }); $description = $facebook_product->get_rich_text_description(); $this->assertEquals('

filtered description

', $description); - // Cleanup - remove_all_filters('facebook_for_woocommerce_fb_rich_text_description'); + // Remove the filter early + $filter->teardown_safely_immediately(); + delete_option(WC_Facebookcommerce_Integration::SETTING_PRODUCT_DESCRIPTION_MODE); } From e6d60c537b53c4c78aa53a9a4d7d7219b878315b Mon Sep 17 00:00:00 2001 From: Paul Kang Date: Thu, 10 Apr 2025 14:07:44 -0700 Subject: [PATCH 06/28] Fixing namespacing issue causing some tests to be skipped (#3037) Summary: This PR addresses a PSR-4 autoloading compliance issue that prevented several PHPUnit tests from being discovered and executed. When running PHPUnit, errors like `Class ... located in ... does not comply with psr-4 autoloading standard. Skipping.` were reported for multiple test files. **Root Cause:** The issue stemmed from a mismatch between the namespace declarations within the test files and the PSR-4 autoloading configuration defined in `composer.json`. Specifically: 1. The `composer.json` file maps the base namespace `WooCommerce\Facebook\Tests\` to the directory `tests/Unit/`. ```json "autoload-dev": { "psr-4": { "WooCommerce\\Facebook\\Tests\\": "tests/Unit" } } ``` 2. According to PSR-4, any subsequent parts of a namespace must correspond directly to the subdirectory structure *under* the base path (`tests/Unit/`). 3. Several test files incorrectly included `Unit` as part of their namespace (e.g., `namespace WooCommerce\Facebook\Tests\Unit\Admin\Settings;`) while residing in paths like `tests/Unit/Admin/Settings/`. 4. This caused the autoloader to look for the class file in an incorrect, nested path (e.g., `tests/Unit/Unit/Admin/Settings/ConnectionTest.php`) instead of the actual location (`tests/Unit/Admin/Settings/ConnectionTest.php`), leading to the "does not comply" error. 5. In some cases, minor discrepancies in directory names (e.g., `API\Plugin` in the namespace vs. `Api\REST` in the path) also contributed to the problem. Thankfully, running `composer dump-autoload -o` was sufficient to uncover all the PSR skipped tests: Class WooCommerce\Facebook\Tests\Unit\Framework\Utilities\AsyncRequestTest located in ./tests/Unit/Framework/Utilities/AsyncRequestTest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\ConnectionTest located in ./tests/Unit/Admin/Settings/ConnectionTest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\Admin\Settings\ShopsTest located in ./tests/Unit/Admin/Settings/ShopsTest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\Admin\Settings_Screens\ConnectionTest located in ./tests/Unit/Admin/Settings_Screens/ConnectionTest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\Admin\Settings_Screens\ShopsTest located in ./tests/Unit/Admin/Settings_Screens/ShopsTest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\API\Plugin\RestAPITest located in ./tests/Unit/Api/REST/RestAPITest.php does not comply with psr-4 autoloading standard. Skipping. Class WooCommerce\Facebook\Tests\Unit\Handlers\MetaExtensionTest located in ./tests/Unit/Handlers/MetaExtensionTest.php does not comply with psr-4 autoloading standard. Skipping. **Changes:** The `namespace` declarations in the affected test files have been modified to accurately reflect their directory structure relative to the `tests/Unit/` base path defined in `composer.json`. This typically involved removing the redundant `Unit` segment from the namespace or correcting other segments to match the actual folder names (e.g., changing `API\Plugin` to `Api\REST`). These changes ensure that the class namespaces correctly map to their file locations according to the PSR-4 standard, allowing Composer's autoloader and PHPUnit to find and execute the tests properly. Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3037 Test Plan: 1. Checked out the branch containing these changes. 2. Ran `composer dump-autoload` to ensure the autoloader cache was updated. 3. Executed the PHPUnit test suite using the standard command (`vendor/bin/phpunit` or `composer test-unit`). 4. **Result:** PHPUnit completed its run successfully, executing 286 tests without the previous PSR-4 autoloading errors. **!---- (auto-generated) DO NOT EDIT OR PUT ANYTHING AFTER THIS LINE ----!** MFTRunTestsScript Run / Test Suite: sa_checkout / Test Collection: www / Diff Version V3 https://internalfb.com/intern/testinfra/testrun/13229323982953381 MFTRunTestsScript Run / Test Suite: sa_checkout / Test Collection: bloks / Diff Version V3 https://internalfb.com/intern/testinfra/testrun/12103424076120115 Reviewed By: ajello-meta, nealweiMeta Differential Revision: D72797935 Pulled By: sol-loup fbshipit-source-id: 9726b427374c91dea93e093382583ff642c7b8f7 --- tests/Unit/AbstractWPUnitTestWithSafeFiltering.php | 2 +- tests/Unit/Admin/Settings/ConnectionTest.php | 2 +- tests/Unit/ApiTest.php | 2 +- tests/Unit/WCFacebookCommerceIntegrationTest.php | 2 +- tests/Unit/WPMLTest.php | 2 +- tests/Unit/fbproductTest.php | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php b/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php index fc199a942..fe9f11b89 100644 --- a/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php +++ b/tests/Unit/AbstractWPUnitTestWithSafeFiltering.php @@ -3,7 +3,7 @@ * Abstract test case for unit tests. */ -namespace WooCommerce\Facebook\Tests\Unit; +namespace WooCommerce\Facebook\Tests; use WP_UnitTestCase; diff --git a/tests/Unit/Admin/Settings/ConnectionTest.php b/tests/Unit/Admin/Settings/ConnectionTest.php index 18a94e4d2..b525ca3f0 100644 --- a/tests/Unit/Admin/Settings/ConnectionTest.php +++ b/tests/Unit/Admin/Settings/ConnectionTest.php @@ -1,6 +1,6 @@ Date: Wed, 14 May 2025 12:57:16 +0100 Subject: [PATCH 07/28] Updating version to 3.4.9 --- changelog.txt | 8 ++++++++ facebook-for-woocommerce.php | 4 ++-- package.json | 2 +- readme.txt | 16 ++++++++-------- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/changelog.txt b/changelog.txt index 4cbba4b58..e4728b60e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,13 @@ *** Facebook for WooCommerce Changelog *** += 3.4.9 - 2025-05-14 = +* Add - Support for rollout switches in the plugin to control feature rollouts from meta side @francorisso in #3126 +* Fix - Tests in the rollout switches file by @francorisso in #3146 +* Fix - RolloutSwitches Init by @carterbuce in #3157 +* Add - Integrate Whatsapp Utility Messaging for WooCommerce Order Update Notifications by @sharunaanandraj in #3164 +* Tweak - Improve Test Filter Management with AbstractWPUnitTestWithSafeFiltering by @sol-loup in #2944 +* Fix - Namespacing issue causing some tests to be skipped @sol-loup in #3037 + = 3.4.8 - 2025-05-06 = * Add - Feature to sync global attributes to Meta and test API format by @devbodaghe in #3050 * Fix - Facebook attribute dropdown display and syncing issues by @devbodaghe in #3051 diff --git a/facebook-for-woocommerce.php b/facebook-for-woocommerce.php index 24f73984b..e7552f758 100644 --- a/facebook-for-woocommerce.php +++ b/facebook-for-woocommerce.php @@ -10,7 +10,7 @@ * Description: Grow your business on Facebook! Use this official plugin to help sell more of your products using Facebook. After completing the setup, you'll be ready to create ads that promote your products and you can also create a shop section on your Page where customers can browse your products on Facebook. * Author: Facebook * Author URI: https://www.facebook.com/ - * Version: 3.4.8 + * Version: 3.4.9 * Requires at least: 5.6 * Requires PHP: 7.4 * Text Domain: facebook-for-woocommerce @@ -48,7 +48,7 @@ class WC_Facebook_Loader { /** * @var string the plugin version. This must be in the main plugin file to be automatically bumped by Woorelease. */ - const PLUGIN_VERSION = '3.4.8'; // WRCS: DEFINED_VERSION. + const PLUGIN_VERSION = '3.4.9'; // WRCS: DEFINED_VERSION. // Minimum PHP version required by this plugin. const MINIMUM_PHP_VERSION = '7.4.0'; diff --git a/package.json b/package.json index 0357d1ff5..55ee5a7b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "facebook-for-woocommerce", - "version": "3.4.8", + "version": "3.4.9", "author": "Facebook", "homepage": "https://woocommerce.com/products/facebook/", "license": "GPL-2.0", diff --git a/readme.txt b/readme.txt index 0d89311cd..14a74442a 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: facebook Tags: meta, facebook, conversions api, catalog sync, ads Requires at least: 5.6 Tested up to: 6.7 -Stable tag: 3.4.8 +Stable tag: 3.4.9 Requires PHP: 7.4 MySQL: 5.6 or greater License: GPLv2 or later @@ -40,12 +40,12 @@ When opening a bug on GitHub, please give us as many details as possible. == Changelog == -= 3.4.8 - 2025-05-06 = -* Add - Feature to sync global attributes to Meta and test API format by @devbodaghe in #3050 -* Fix - Facebook attribute dropdown display and syncing issues by @devbodaghe in #3051 -* Tweak - Set helper text for dropdown sync by @devbodaghe in #3104 -* Tweak - Remove unused condition field code for variable products by @devbodaghe in #3114 -* Fix - Cursor style not resetting after attribute removal by @devbodaghe in #3113 -* Fix - 'Call to a member function is_taxonomy() on string' error when processing variable products by @devbodaghe in #3155 += 3.4.9 - 2025-05-14 = +* Add - Support for rollout switches in the plugin to control feature rollouts from meta side @francorisso in #3126 +* Fix - Tests in the rollout switches file by @francorisso in #3146 +* Fix - RolloutSwitches Init by @carterbuce in #3157 +* Add - Integrate Whatsapp Utility Messaging for WooCommerce Order Update Notifications by @sharunaanandraj in #3164 +* Tweak - Improve Test Filter Management with AbstractWPUnitTestWithSafeFiltering by @sol-loup in #2944 +* Fix - Namespacing issue causing some tests to be skipped @sol-loup in #3037 [See changelog for all versions](https://raw.githubusercontent.com/facebook/facebook-for-woocommerce/refs/heads/releases/changelog.txt). From d4cc95913b23538b4228bf05eaca91e0e4f552f9 Mon Sep 17 00:00:00 2001 From: Andrea D'Souza Date: Mon, 12 May 2025 10:57:19 -0700 Subject: [PATCH 08/28] Additional logs, api timeouts to Utility Messaging Flows (#3171) Summary: ## Description Added additional logs to help debug Utility Message Flows. Also added timeout to all the API calls ### Type of change - New feature (non-breaking change which adds functionality) ## Changelog entry Additional logs and timeout for Utility Message Flows Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3171 Test Plan: ## Screen Recording https://pxl.cl/7cVzm ## Logs {F1977866917} Reviewed By: sharunaanandraj Differential Revision: D74555794 Privacy Context Container: L1332882 Pulled By: woo-ardsouza fbshipit-source-id: 56208f3a5c87b7c36170add7df92839ad077ea72 --- assets/js/admin/whatsapp-events.js | 15 +++++++++----- assets/js/admin/whatsapp-templates.js | 4 ++++ .../Handlers/WhatsAppUtilityConnection.php | 20 ++++++++++++++++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/assets/js/admin/whatsapp-events.js b/assets/js/admin/whatsapp-events.js index bfaed97df..840175efa 100644 --- a/assets/js/admin/whatsapp-events.js +++ b/assets/js/admin/whatsapp-events.js @@ -7,11 +7,11 @@ * @package FacebookCommerce */ -jQuery( document ).ready( function( $ ) { +jQuery(document).ready(function ($) { // Set Event Status for Order Placed var orderPlacedActiveStatus = $('#order-placed-active-status'); var orderPlacedInactiveStatus = $('#order-placed-inactive-status'); - if(facebook_for_woocommerce_whatsapp_events.order_placed_enabled){ + if (facebook_for_woocommerce_whatsapp_events.order_placed_enabled) { orderPlacedInactiveStatus.hide(); orderPlacedActiveStatus.show(); } @@ -23,7 +23,7 @@ jQuery( document ).ready( function( $ ) { // Set Event Status for Order FulFilled var orderFulfilledActiveStatus = $('#order-fulfilled-active-status'); var orderFulfilledInactiveStatus = $('#order-fulfilled-inactive-status'); - if(facebook_for_woocommerce_whatsapp_events.order_fulfilled_enabled){ + if (facebook_for_woocommerce_whatsapp_events.order_fulfilled_enabled) { orderFulfilledInactiveStatus.hide(); orderFulfilledActiveStatus.show(); } @@ -35,7 +35,7 @@ jQuery( document ).ready( function( $ ) { // Set Event Status for Order Refunded var orderRefundedActiveStatus = $('#order-refunded-active-status'); var orderRefundedInactiveStatus = $('#order-refunded-inactive-status'); - if(facebook_for_woocommerce_whatsapp_events.order_refunded_enabled){ + if (facebook_for_woocommerce_whatsapp_events.order_refunded_enabled) { orderRefundedInactiveStatus.hide(); orderRefundedActiveStatus.show(); } @@ -49,7 +49,7 @@ jQuery( document ).ready( function( $ ) { $('#woocommerce-whatsapp-manage-order-placed, #woocommerce-whatsapp-manage-order-fulfilled, #woocommerce-whatsapp-manage-order-refunded').click(function (event) { var clickedButtonId = $(event.target).attr("id"); - let view=clickedButtonId.replace("woocommerce-whatsapp-", ""); + let view = clickedButtonId.replace("woocommerce-whatsapp-", ""); view = view.replaceAll("-", "_"); let url = new URL(window.location.href); let params = new URLSearchParams(url.search); @@ -90,6 +90,10 @@ jQuery( document ).ready( function( $ ) {

${button}

`).show(); } + console.log('Whatsapp Library Template call succeeded', response); + } + else { + console.log('Whatsapp Library Template call failed', response); } }); }); @@ -111,6 +115,7 @@ jQuery( document ).ready( function( $ ) { params.set('view', 'utility_settings'); url.search = params.toString(); window.location.href = url.toString(); + console.log('Whatsapp Event Config has been updated', response); }); }); diff --git a/assets/js/admin/whatsapp-templates.js b/assets/js/admin/whatsapp-templates.js index be01ba924..5fd150242 100644 --- a/assets/js/admin/whatsapp-templates.js +++ b/assets/js/admin/whatsapp-templates.js @@ -16,11 +16,15 @@ jQuery( document ).ready( function( $ ) { }, function ( response ) { console.log(response); if ( response.success ) { + console.log( 'Whatsapp Template Insights Info was fetched successfully', response ); var business_id = response.data.business_id; var asset_id = response.data.waba_id; const MANAGE_TEMPLATES_URL = `https://business.facebook.com/latest/whatsapp_manager/message_templates?business_id=${business_id}&asset_id=${asset_id}`; window.open(MANAGE_TEMPLATES_URL); } + else { + console.log( 'Whatsapp Template Insights Info fetch call failed', response ); + } } ); }); } ); diff --git a/includes/Handlers/WhatsAppUtilityConnection.php b/includes/Handlers/WhatsAppUtilityConnection.php index 4c1bce8ec..c18205559 100644 --- a/includes/Handlers/WhatsAppUtilityConnection.php +++ b/includes/Handlers/WhatsAppUtilityConnection.php @@ -67,6 +67,7 @@ public static function get_template_library_content( $event, $bisu_token ) { 'Authorization' => $bisu_token, ), 'body' => array(), + 'timeout' => 300, // 5 minutes ); $response = wp_remote_request( $url, $options ); @@ -113,6 +114,7 @@ public static function wc_facebook_whatsapp_connect_utility_messages_call( $waba 'Authorization' => $bisu_token, ), 'body' => array(), + 'timeout' => 300, // 5 minutes ); $response = wp_remote_post( $base_url, $options ); wc_get_logger()->info( @@ -169,6 +171,7 @@ public static function wc_facebook_disconnect_whatsapp( $waba_id, $integration_c 'Authorization' => $bisu_token, ), 'body' => array(), + 'timeout' => 300, // 5 minutes ); $response = wp_remote_post( $base_url, $options ); wc_get_logger()->info( @@ -284,6 +287,13 @@ public static function post_whatsapp_utility_messages_event_configs_call( $event $data = explode( "\n", wp_remote_retrieve_body( $response ) ); $response_object = json_decode( $data[0] ); $is_error = is_wp_error( $response ); + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Event Configs Post API call Response: %1$s ', 'facebook-for-woocommerce' ), + json_encode( $response ), + ) + ); if ( is_wp_error( $response ) || 200 !== $status_code ) { $error_message = $response_object->error->error_user_title ?? $response_object->error->message ?? 'Something went wrong. Please try again later!'; wc_get_logger()->info( @@ -349,7 +359,7 @@ public static function post_whatsapp_utility_messages_events_call( $event, $even $name = self::EVENT_TO_LIBRARY_TEMPLATE_MAPPING[ $event ]; $components = self::get_components_for_event( $event, $order_id, $first_name, $refund_value, $currency ); $options = array( - 'body' => array( + 'body' => array( 'messaging_product' => 'whatsapp', 'to' => $phone_number, 'template' => array( @@ -361,11 +371,19 @@ public static function post_whatsapp_utility_messages_events_call( $event, $even ), 'type' => 'template', ), + 'timeout' => 300, // 5 minutes ); $response = wp_remote_post( $base_url, $options ); $status_code = wp_remote_retrieve_response_code( $response ); $data = explode( "\n", wp_remote_retrieve_body( $response ) ); $response_object = json_decode( $data[0] ); + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Messages Post API call Response: %1$s ', 'facebook-for-woocommerce' ), + json_encode( $response ), + ) + ); if ( is_wp_error( $response ) || 200 !== $status_code ) { $error_message = $response_object->error->error_user_title ?? $response_object->error->message ?? 'Something went wrong. Please try again later!'; wc_get_logger()->info( From 299b74bc9b20e3bd968c8f461dde2878e8bcc386 Mon Sep 17 00:00:00 2001 From: sharunaanandraj Date: Mon, 12 May 2025 15:38:20 -0700 Subject: [PATCH 09/28] Fix the WAUM payment progress to only Show Up after Consent Collection is Enabled (#3175) Summary: ## Description Fix the payment progress to only Show Up after Consent Collection is Enabled ### Type of change - Bug fix (non-breaking change which fixes an issue) ## Changelog entry Fix the WAUM payment progress to only Show Up after Consent Collection is Enabled Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3175 Test Plan: ### Before https://github.com/user-attachments/assets/011012db-3378-4fdc-89b2-4d41be683928 ### After https://github.com/user-attachments/assets/e8500ba3-8d4f-4f5c-8591-e6ab43e4c9e7 https://pxl.cl/7d7gF Reviewed By: woo-ardsouza Differential Revision: D74609418 Privacy Context Container: L1332882 Pulled By: sharunaanandraj fbshipit-source-id: 1bc27f5a89ff7c63d09970823f13af57b6052fa5 --- assets/js/admin/whatsapp-connection.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/assets/js/admin/whatsapp-connection.js b/assets/js/admin/whatsapp-connection.js index e6674fcf6..e1bf1ad26 100644 --- a/assets/js/admin/whatsapp-connection.js +++ b/assets/js/admin/whatsapp-connection.js @@ -52,12 +52,6 @@ jQuery( document ).ready( function( $ ) { $('#wc-fb-whatsapp-consent-subcontent').show(); $('#wc-fb-whatsapp-consent-button-wrapper').show(); - // update the progress of payment step if payment already setup - if(response.data['is_payment_setup'] === true) { - $('#wc-fb-whatsapp-billing-inprogress').hide(); - $('#wc-fb-whatsapp-billing-notstarted').hide(); - $('#wc-fb-whatsapp-billing-success').show(); - } } else { console.log('Whatsapp connection is not complete. Checking again in 5 seconds:', response, ', retry attempt:', retryCount, 'pollingTimeout', pollingTimeout); if(retryCount >= pollingTimeout) { From c56b1d7893d99ac4147291639f814ee5002e7c8e Mon Sep 17 00:00:00 2001 From: Andrea D'Souza Date: Mon, 12 May 2025 18:58:48 -0700 Subject: [PATCH 10/28] Updated positional parameters to named parameters in UI Summary: ## Description Replaced the positional parameters configured in the header and body text to named parameters defined in the Figma so that the users are aware of the parameter values being replaced. {F1977895058} {F1977895057} {F1977895056} ### Type of change - New feature (non-breaking change which adds functionality) ## Changelog entry Updated positional parameters to named parameters in UI Reviewed By: sharunaanandraj Differential Revision: D74615150 Privacy Context Container: L1332882 fbshipit-source-id: d6437753bf6daf85a80f2e7e6559b35329f138a8 --- assets/js/admin/whatsapp-events.js | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/assets/js/admin/whatsapp-events.js b/assets/js/admin/whatsapp-events.js index 840175efa..5e33174f5 100644 --- a/assets/js/admin/whatsapp-events.js +++ b/assets/js/admin/whatsapp-events.js @@ -66,11 +66,40 @@ jQuery(document).ready(function ($) { event: facebook_for_woocommerce_whatsapp_events.event, }, function (response) { if (response.success) { + const event = facebook_for_woocommerce_whatsapp_events.event; + const headerReplacements = { + "ORDER_REFUNDED": { + "{{1}}": "{{$amount}}" + } + }; + const bodyReplacements = { + "ORDER_PLACED": { + "{{1}}": "{{first_name}}", + "{{2}}": "#{{order_number}}" + }, + "ORDER_FULFILLED": { + "{{1}}": "{{first_name}}", + "{{2}}": "#{{order_number}}" + }, + "ORDER_REFUNDED": { + "{{1}}": "{{first_name}}", + "{{2}}": "{{$amount}}", + "{{3}}": "#{{order_number}}" + } + }; const parsedData = JSON.parse(response.data); const apiResponseData = parsedData.data[0]; // Parse template strings as HTML and extract text content to sanitize text - const header = $.parseHTML(apiResponseData.header)[0].textContent; - const body = $.parseHTML(apiResponseData.body)[0].textContent; + var header = $.parseHTML(apiResponseData.header)[0].textContent; + header = header.replace(/{{\d+}}/g, function (match) { + return headerReplacements[event][match]; + }); + var body = $.parseHTML(apiResponseData.body)[0].textContent; + body = body.replace(/{{\d+}}/g, function(match) { + return bodyReplacements[event][match]; + }); + // Body content has line breaks that need to be rendered in html + body = body.replace(/\n/g, '
'); if (facebook_for_woocommerce_whatsapp_events.event === "ORDER_REFUNDED") { $('#library-template-content').html(`

Header

From 45bc1c8df354e3fca775c100c170892a648d123c Mon Sep 17 00:00:00 2001 From: Andrea D'Souza Date: Tue, 13 May 2025 10:13:11 -0700 Subject: [PATCH 11/28] UI changes to dynamically support languages dropdown (#3178) Summary: ## Description This change makes a call to the Integration Config GET API to dynamically get the list of supported languages in the languages dropdown instead of the hardcoded values ### Type of change - New feature (non-breaking change which adds functionality) ## Changelog entry Update language dropdown based on supported_languages in GET api response Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3178 Test Plan: Screenshot of dropdown with loaded languages {F1977914187} **Screen Recording** * Setup Order Placed event config * Validate Order Placed message is sent https://pxl.cl/7dm7x {F1977917367} Reviewed By: sharunaanandraj Differential Revision: D74652600 Privacy Context Container: L1332882 Pulled By: woo-ardsouza fbshipit-source-id: 6768ee31feff58f3287d631492129dea90d856a9 --- assets/js/admin/whatsapp-events.js | 33 ++++++++++++-- includes/AJAX.php | 31 +++++++++++++ .../Settings_Screens/Whatsapp_Utility.php | 16 ------- .../Handlers/WhatsAppUtilityConnection.php | 44 +++++++++++++++++++ 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/assets/js/admin/whatsapp-events.js b/assets/js/admin/whatsapp-events.js index 5e33174f5..51916bf02 100644 --- a/assets/js/admin/whatsapp-events.js +++ b/assets/js/admin/whatsapp-events.js @@ -44,9 +44,6 @@ jQuery(document).ready(function ($) { orderRefundedInactiveStatus.show(); } - var eventConfiglanguage = getEventLanguage(facebook_for_woocommerce_whatsapp_events.event); - $("#manage-event-language").val(eventConfiglanguage); - $('#woocommerce-whatsapp-manage-order-placed, #woocommerce-whatsapp-manage-order-fulfilled, #woocommerce-whatsapp-manage-order-refunded').click(function (event) { var clickedButtonId = $(event.target).attr("id"); let view = clickedButtonId.replace("woocommerce-whatsapp-", ""); @@ -148,6 +145,36 @@ jQuery(document).ready(function ($) { }); }); + $("#manage-event-language").load(facebook_for_woocommerce_whatsapp_events.ajax_url, function () { + $.post(facebook_for_woocommerce_whatsapp_events.ajax_url, { + action: 'wc_facebook_whatsapp_fetch_supported_languages', + nonce: facebook_for_woocommerce_whatsapp_events.nonce, + }, function (response) { + if (response.success) { + const parsedData = JSON.parse(response.data); + const supportedLanguages = parsedData.supported_languages; + $.each(supportedLanguages, function (index, languageObj) { + var displayValue = $.parseHTML(languageObj.display_value)[0].textContent; + var locale = $.parseHTML(languageObj.locale)[0].textContent; + $("#manage-event-language").append($("").text(displayValue).val(locale)); + }); + var eventConfiglanguage = getEventLanguage(facebook_for_woocommerce_whatsapp_events.event); + $("#manage-event-language").val(eventConfiglanguage); + console.log('Fetch supported language call succeeded'); + } + else { + console.log('Fetch supported language call failed', response); + const message = facebook_for_woocommerce_whatsapp_finish.i18n.generic_error; + const errorNoticeHtml = ` +
+

${message}

+
+ `; + $('#events-error-notice').html(errorNoticeHtml).show(); + } + }); + }); + function getEventLanguage(event) { switch (event) { case "ORDER_PLACED": diff --git a/includes/AJAX.php b/includes/AJAX.php index 72108984f..1f55794f5 100644 --- a/includes/AJAX.php +++ b/includes/AJAX.php @@ -73,6 +73,9 @@ public function __construct() { // disconnect whatsapp account from woocommcerce app add_action( 'wp_ajax_wc_facebook_disconnect_whatsapp', array( $this, 'wc_facebook_disconnect_whatsapp' ) ); + + // get supported languages for whatsapp templates + add_action( 'wp_ajax_wc_facebook_whatsapp_fetch_supported_languages', array( $this, 'whatsapp_fetch_supported_languages' ) ); } @@ -385,6 +388,34 @@ public function whatsapp_fetch_library_template_info() { $event = isset( $_POST['event'] ) ? wc_clean( wp_unslash( $_POST['event'] ) ) : ''; WhatsAppUtilityConnection::get_template_library_content( $event, $bisu_token ); } + + public function whatsapp_fetch_supported_languages() { + wc_get_logger()->info( + sprintf( + __( 'Fetching supported languages for WhatsApp Utility Templates', 'facebook-for-woocommerce' ) + ) + ); + if ( ! check_ajax_referer( 'facebook-for-wc-whatsapp-events-nonce', 'nonce', false ) ) { + wc_get_logger()->info( + sprintf( + __( 'Nonce Verification Failed while fetching supported languages for WhatsApp Utility Templates', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Invalid security token sent.' ); + } + $bisu_token = get_option( 'wc_facebook_wa_integration_bisu_access_token', null ); + $integration_config_id = get_option( 'wc_facebook_wa_integration_config_id', null ); + if ( empty( $bisu_token ) || empty( $integration_config_id ) ) { + wc_get_logger()->info( + sprintf( + __( 'Missing Integration Config ID, BISU token, WABA ID for Integration Config Get API call', 'facebook-for-woocommerce' ) + ) + ); + wp_send_json_error( 'Missing integration_config_id or bisu_token for Integration Config Get API call', 'facebook-for-woocommerce' ); + } + WhatsAppUtilityConnection::get_supported_languages_for_templates( $integration_config_id, $bisu_token ); + } + /** * Creates or Updates WhatsApp Utility Event Configs * diff --git a/includes/Admin/Settings_Screens/Whatsapp_Utility.php b/includes/Admin/Settings_Screens/Whatsapp_Utility.php index f56ae3c5c..b5ca06a05 100644 --- a/includes/Admin/Settings_Screens/Whatsapp_Utility.php +++ b/includes/Admin/Settings_Screens/Whatsapp_Utility.php @@ -583,22 +583,6 @@ public function render_manage_events_view() {

diff --git a/includes/Handlers/WhatsAppUtilityConnection.php b/includes/Handlers/WhatsAppUtilityConnection.php index c18205559..90c4f4c35 100644 --- a/includes/Handlers/WhatsAppUtilityConnection.php +++ b/includes/Handlers/WhatsAppUtilityConnection.php @@ -405,6 +405,50 @@ public static function post_whatsapp_utility_messages_events_call( $event, $even } } + /** + * Makes an API call to Integration Config Get API + * + * @param string $integration_config_id Integration Config id + * @param string $bisu_token the BISU token received in the webhook + */ + public static function get_supported_languages_for_templates( $integration_config_id, $bisu_token ) { + $base_url = array( self::GRAPH_API_BASE_URL, self::API_VERSION, $integration_config_id ); + $base_url = esc_url( implode( '/', $base_url ) ); + $params = array( + 'access_token' => $bisu_token, + ); + $url = add_query_arg( $params, $base_url ); + $options = array( + 'headers' => array( + 'Authorization' => $bisu_token, + ), + 'body' => array(), + 'timeout' => 300, // 5 minutes + ); + + $response = wp_remote_request( $url, $options ); + $status_code = wp_remote_retrieve_response_code( $response ); + $data = wp_remote_retrieve_body( $response ); + if ( is_wp_error( $response ) || 200 !== $status_code ) { + wc_get_logger()->info( + sprintf( + /* translators: %s $error_message */ + __( 'Integration Config GET API call Failed %1$s ', 'facebook-for-woocommerce' ), + $data, + ) + ); + wp_send_json_error( $response, 'Integration Config GET API call Failed' ); + } else { + wc_get_logger()->info( + sprintf( + __( 'Integration Config GET API call Succeeded', 'facebook-for-woocommerce' ) + ) + ); + // $response_object = json_decode( $data[0] ); + wp_send_json_success( $data, 'Finish Integration Config API Call' ); + } + } + /** * Gets Component Objects for Order Management Events From adb1bd06bb23beccf27154720964776ddc3e277b Mon Sep 17 00:00:00 2001 From: Andrea D'Souza Date: Tue, 13 May 2025 09:24:41 -0700 Subject: [PATCH 12/28] Error handling for Manage Events view (#3179) Summary: ## Description Added an error notice if the post api call fails while creating or editing event configs, and getting template library content in the Manage Events view ### Type of change - New feature (non-breaking change which adds functionality) ## Changelog entry Error notice to gracefully handle errors in Manage Events view Pull Request resolved: https://github.com/facebook/facebook-for-woocommerce/pull/3179 Test Plan: Error while Creating Event Config {F1977915582} Error while getting Library Template {F1977915583} Reviewed By: sharunaanandraj Differential Revision: D74654606 Privacy Context Container: L1332882 Pulled By: woo-ardsouza fbshipit-source-id: 188e47d3a197c02382fbacca5afe847918b4e0f4 --- ...ebook-for-woocommerce-whatsapp-utility.css | 3 ++ assets/js/admin/whatsapp-events.js | 32 +++++++++++++++---- .../Settings_Screens/Whatsapp_Utility.php | 3 ++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css b/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css index 76fa2dea5..f01faddda 100644 --- a/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css +++ b/assets/css/admin/facebook-for-woocommerce-whatsapp-utility.css @@ -150,6 +150,9 @@ .manage-event-button { margin-left: 20px; } +.manage-event-error-notice { + margin-right: 5px; +} .fbwa-hidden-element { display: none; } diff --git a/assets/js/admin/whatsapp-events.js b/assets/js/admin/whatsapp-events.js index 51916bf02..e83dfa202 100644 --- a/assets/js/admin/whatsapp-events.js +++ b/assets/js/admin/whatsapp-events.js @@ -120,6 +120,13 @@ jQuery(document).ready(function ($) { } else { console.log('Whatsapp Library Template call failed', response); + const message = facebook_for_woocommerce_whatsapp_finish.i18n.generic_error; + const errorNoticeHtml = ` +
+

${message}

+
+ `; + $('#events-error-notice').html(errorNoticeHtml).show(); } }); }); @@ -135,13 +142,24 @@ jQuery(document).ready(function ($) { language: languageValue, status: statusValue }, function (response) { - //TODO: Add Error Handling - let url = new URL(window.location.href); - let params = new URLSearchParams(url.search); - params.set('view', 'utility_settings'); - url.search = params.toString(); - window.location.href = url.toString(); - console.log('Whatsapp Event Config has been updated', response); + if (response.success) { + let url = new URL(window.location.href); + let params = new URLSearchParams(url.search); + params.set('view', 'utility_settings'); + url.search = params.toString(); + window.location.href = url.toString(); + console.log('Whatsapp Event Config has been updated', response); + } + else { + console.log('Whatsapp Event Config Update failure', response); + const message = facebook_for_woocommerce_whatsapp_finish.i18n.generic_error; + const errorNoticeHtml = ` +
+

${message}

+
+ `; + $('#events-error-notice').html(errorNoticeHtml).show(); + } }); }); diff --git a/includes/Admin/Settings_Screens/Whatsapp_Utility.php b/includes/Admin/Settings_Screens/Whatsapp_Utility.php index b5ca06a05..efa705218 100644 --- a/includes/Admin/Settings_Screens/Whatsapp_Utility.php +++ b/includes/Admin/Settings_Screens/Whatsapp_Utility.php @@ -154,6 +154,8 @@ public function enqueue_assets() { 'order_refunded_language' => $order_refunded_language, 'i18n' => array( 'result' => true, + 'generic_error' => __( 'Something went wrong. Please try again.', 'facebook-for-woocommerce' ), + ), ) ); @@ -640,6 +642,7 @@ public function render_manage_events_view() {
+